refactor: align repo layout and logging safeguards

This commit is contained in:
qzl
2026-01-29 17:02:09 +08:00
parent 6af0989fe7
commit c2e65fa157
56 changed files with 1881 additions and 890 deletions
View File
View File
+3
View File
@@ -0,0 +1,3 @@
from .settings import Settings, config
__all__ = ["Settings", "config"]
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from pathlib import Path
from typing import ClassVar, Literal
from pydantic import BaseModel, Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class RuntimeSettings(BaseModel):
environment: Literal["dev", "test", "prod"] = "dev"
debug: bool = True
log_level: str = "INFO"
log_json: bool = True
log_rotation: Literal["time", "size", "none"] = "time"
log_rotation_when: str = "midnight"
log_rotation_interval: int = 1
log_rotation_backup_count: int = 14
log_rotation_max_bytes: int = 10_000_000
log_dir: str = "logs"
log_error_dir: str = "logs/errors"
log_file_name: str = "app.log"
log_error_file_name: str = "error.log"
log_sensitive_fields: list[str] = Field(
default_factory=lambda: [
"password",
"secret",
"token",
"api_key",
"authorization",
"cookie",
"client_ip",
"user_id",
]
)
sql_log_queries: bool = False
class AppSettings(BaseModel):
host: str = "0.0.0.0"
port: int = Field(default=8000, ge=1, le=65535)
reload: bool = True
class CorsSettings(BaseModel):
allow_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost",
"http://localhost:3000",
]
)
allow_credentials: bool = True
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
class SupabaseSettings(BaseModel):
url: str = "http://localhost:8001"
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
jwt_secret: str | None = None
class InfraSupabaseSettings(BaseModel):
public_url: str = "http://localhost:8001"
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
class InfraJwtSettings(BaseModel):
secret: str = "CHANGE_ME"
class InfraSettings(BaseModel):
api_external_url: str = "http://localhost:8001"
supabase: InfraSupabaseSettings = Field(default_factory=InfraSupabaseSettings)
jwt: InfraJwtSettings = Field(default_factory=InfraJwtSettings)
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
for parent in [current, *current.parents]:
candidate = parent / ".env"
if candidate.is_file():
return str(candidate)
return ".env"
class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
app: AppSettings = AppSettings()
cors: CorsSettings = CorsSettings()
infra: InfraSettings = Field(default_factory=InfraSettings)
@computed_field
def supabase(self) -> SupabaseSettings:
return SupabaseSettings(
url=self.infra.supabase.public_url or self.infra.api_external_url,
anon_key=self.infra.supabase.anon_key,
service_role_key=self.infra.supabase.service_role_key,
jwt_secret=self.infra.jwt.secret,
)
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_file=_resolve_env_file(),
env_prefix="SOCIAL_",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
config = Settings()
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from core.logging import celery
from core.logging.config import configure_logging
from core.logging.context import bind_context, clear_context, get_context
from core.logging.logger import get_logger
__all__ = [
"bind_context",
"celery",
"clear_context",
"configure_logging",
"get_context",
"get_logger",
]
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from celery import Celery, signals
from core.config.settings import Settings
from core.logging.config import configure_logging
from core.logging.context import bind_context, clear_context
@dataclass(frozen=True)
class CelerySignalHandlers:
on_setup_logging: Callable[..., None]
on_after_setup_task_logger: Callable[..., None]
on_task_prerun: Callable[..., None]
on_task_postrun: Callable[..., None]
def build_celery_signal_handlers(
settings: Settings | None = None,
) -> CelerySignalHandlers:
def on_setup_logging(*_args: object, **_kwargs: object) -> None:
configure_logging(settings)
def on_after_setup_task_logger(*_args: object, **_kwargs: object) -> None:
configure_logging(settings)
def on_task_prerun(*_args: object, **kwargs: object) -> None:
task_id = cast(str | None, kwargs.get("task_id"))
task = kwargs.get("task")
task_name = getattr(task, "name", None)
bind_context(task_id=task_id, task_name=task_name)
def on_task_postrun(*_args: object, **_kwargs: object) -> None:
clear_context()
return CelerySignalHandlers(
on_setup_logging=on_setup_logging,
on_after_setup_task_logger=on_after_setup_task_logger,
on_task_prerun=on_task_prerun,
on_task_postrun=on_task_postrun,
)
def configure_celery_app(app: Celery, settings: Settings | None = None) -> None:
app.conf.worker_hijack_root_logger = False
handlers = build_celery_signal_handlers(settings)
signals.setup_logging.connect(handlers.on_setup_logging, weak=False)
signals.after_setup_task_logger.connect(
handlers.on_after_setup_task_logger, weak=False
)
signals.task_prerun.connect(handlers.on_task_prerun, weak=False)
signals.task_postrun.connect(handlers.on_task_postrun, weak=False)
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
from logging.config import dictConfig
from pathlib import Path
import structlog
from core.config.settings import RuntimeSettings, Settings
from core.logging.formatters import (
build_plain_formatter,
build_processor_formatter,
ensure_message_key,
)
from core.logging.filters import build_sensitive_data_processor
from core.logging.handlers import build_file_handler_config
def _ensure_log_dirs(runtime: RuntimeSettings) -> None:
Path(runtime.log_dir).mkdir(parents=True, exist_ok=True)
Path(runtime.log_error_dir).mkdir(parents=True, exist_ok=True)
def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
log_dir = Path(runtime.log_dir)
error_dir = Path(runtime.log_error_dir)
formatter_name = "json" if runtime.log_json else "plain"
file_handler = build_file_handler_config(
runtime,
file_path=log_dir / runtime.log_file_name,
level=runtime.log_level,
formatter=formatter_name,
)
error_handler = build_file_handler_config(
runtime,
file_path=error_dir / runtime.log_error_file_name,
level="ERROR",
formatter=formatter_name,
filters=["error_only"],
)
return {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"error_only": {
"()": "core.logging.filters.ErrorLevelFilter",
}
},
"formatters": {
"json": {
"()": build_processor_formatter,
"sensitive_fields": runtime.log_sensitive_fields,
},
"plain": {
"()": build_plain_formatter,
"sensitive_fields": runtime.log_sensitive_fields,
},
},
"handlers": {
"file": file_handler,
"error": error_handler,
},
"root": {
"handlers": ["file", "error"],
"level": runtime.log_level,
},
}
def configure_logging(settings: Settings | None = None) -> None:
active_settings = settings or Settings()
runtime = active_settings.runtime
try:
_ensure_log_dirs(runtime)
dictConfig(build_logging_config(runtime))
except (OSError, ValueError) as exc:
logging.basicConfig(level=runtime.log_level)
logging.getLogger(__name__).error("Logging setup failed", exc_info=exc)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
parameters=[
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
build_sensitive_data_processor(runtime.log_sensitive_fields),
ensure_message_key,
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from structlog import contextvars
def bind_context(**values: object) -> None:
contextvars.bind_contextvars(**values)
def clear_context() -> None:
contextvars.clear_contextvars()
def get_context() -> dict[str, object]:
return contextvars.get_contextvars()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from typing import cast
from structlog.types import EventDict
_NORMALIZE_PATTERN = re.compile(r"[^a-z0-9]")
def _normalize_key(value: str) -> str:
return _NORMALIZE_PATTERN.sub("", value.lower())
def _is_sensitive_key(key: object, sensitive_fields: set[str]) -> bool:
normalized_key = _normalize_key(str(key))
return normalized_key in sensitive_fields or any(
fragment in normalized_key for fragment in sensitive_fields
)
def _redact_value(value: object, sensitive_fields: set[str]) -> object:
if isinstance(value, dict):
typed_value = cast(dict[str, object], value)
return {
key: (
"[REDACTED]"
if _is_sensitive_key(key, sensitive_fields)
else _redact_value(inner, sensitive_fields)
)
for key, inner in typed_value.items()
}
if isinstance(value, list):
return [_redact_value(item, sensitive_fields) for item in value]
return value
def build_sensitive_data_processor(
sensitive_fields: list[str],
) -> Callable[[object, str, EventDict], EventDict]:
normalized = {_normalize_key(field) for field in sensitive_fields}
def processor(
_logger: object, _method_name: str, event_dict: EventDict
) -> EventDict:
return cast(EventDict, _redact_value(event_dict, normalized))
return processor
class ErrorLevelFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno >= logging.ERROR
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from structlog.dev import ConsoleRenderer
from structlog.processors import JSONRenderer
from structlog.stdlib import ProcessorFormatter
from structlog.types import EventDict
import structlog
from core.logging.filters import build_sensitive_data_processor
def ensure_message_key(
_logger: object, _method_name: str, event_dict: EventDict
) -> EventDict:
if "message" in event_dict:
return event_dict
if "event" not in event_dict:
return event_dict
without_event = {key: value for key, value in event_dict.items() if key != "event"}
return {**without_event, "message": event_dict["event"]}
def build_processor_formatter(
sensitive_fields: list[str] | None = None,
) -> ProcessorFormatter:
redact = build_sensitive_data_processor(sensitive_fields or [])
return ProcessorFormatter(
foreign_pre_chain=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
parameters=[
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
structlog.stdlib.ExtraAdder(),
ensure_message_key,
],
processors=[
redact,
ensure_message_key,
ProcessorFormatter.remove_processors_meta,
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
JSONRenderer(sort_keys=True),
],
)
def build_plain_formatter(
sensitive_fields: list[str] | None = None,
) -> ProcessorFormatter:
redact = build_sensitive_data_processor(sensitive_fields or [])
return ProcessorFormatter(
foreign_pre_chain=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
parameters=[
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
structlog.stdlib.ExtraAdder(),
ensure_message_key,
],
processors=[
redact,
ensure_message_key,
ProcessorFormatter.remove_processors_meta,
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
ConsoleRenderer(colors=False),
],
)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from core.config.settings import RuntimeSettings
def build_file_handler_config(
runtime: RuntimeSettings,
file_path: Path,
level: str,
formatter: str,
filters: list[str] | None = None,
) -> dict[str, object]:
filter_list = list(filters or [])
base_config: dict[str, object] = {
"level": level,
"formatter": formatter,
"filename": str(file_path),
"encoding": "utf-8",
}
if filter_list:
base_config = {**base_config, "filters": filter_list}
if runtime.log_rotation == "time":
return {
**base_config,
"class": "logging.handlers.TimedRotatingFileHandler",
"when": runtime.log_rotation_when,
"interval": runtime.log_rotation_interval,
"backupCount": runtime.log_rotation_backup_count,
}
if runtime.log_rotation == "size":
return {
**base_config,
"class": "logging.handlers.RotatingFileHandler",
"maxBytes": runtime.log_rotation_max_bytes,
"backupCount": runtime.log_rotation_backup_count,
}
return {
**base_config,
"class": "logging.FileHandler",
}
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
import structlog
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import re
from collections.abc import MutableMapping
from typing import cast
from uuid import uuid4
from fastapi import FastAPI, Request
from starlette.requests import Request as StarletteRequest
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send
from core.logging.context import bind_context, clear_context
from core.logging.logger import get_logger
class RequestContextMiddleware:
app: ASGIApp
_header_name: str
_request_id_pattern: re.Pattern[str]
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
self.app = app
self._header_name = header_name
self._request_id_pattern = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
request = StarletteRequest(scope, receive=receive)
request_id = self._normalize_request_id(request.headers.get(self._header_name))
client_ip = request.client.host if request.client else None
user_id = getattr(request.state, "user_id", None)
request.state.request_id = request_id
bind_context(
request_id=request_id,
method=request.method,
path=request.url.path,
client_ip=client_ip,
user_id=user_id,
)
async def send_wrapper(message: MutableMapping[str, object]) -> None:
if message.get("type") == "http.response.start":
raw_headers = message.get("headers")
headers = list(cast(list[tuple[bytes, bytes]], raw_headers or []))
header_key = self._header_name.lower().encode()
if not any(item[0].lower() == header_key for item in headers):
headers.append((header_key, request_id.encode()))
message = {**message, "headers": headers}
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
clear_context()
def _normalize_request_id(self, request_id: str | None) -> str:
if request_id and self._request_id_pattern.match(request_id):
return request_id
return str(uuid4())
def register_exception_handlers(app: FastAPI) -> None:
logger = get_logger("core.logging.exception")
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
request_id = getattr(request.state, "request_id", None)
logger.exception(
"Unhandled exception",
error_type=exc.__class__.__name__,
request_id=request_id,
)
headers = {"X-Request-ID": request_id} if request_id else None
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error"},
headers=headers,
)
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
import sys
from pathlib import Path
def pytest_configure() -> None:
root = Path(__file__).resolve().parents[2]
src_path = root / "api" / "src"
if str(src_path) not in sys.path:
sys.path.append(str(src_path))
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
import socket
import threading
import time
from pathlib import Path
from fastapi import FastAPI
from playwright.sync_api import sync_playwright
import uvicorn
from core.config.settings import Settings
from core.logging.config import configure_logging
from core.logging.middleware import (
RequestContextMiddleware,
register_exception_handlers,
)
def _read_json_lines(path: Path) -> list[dict[str, object]]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex((host, port)) == 0:
return
time.sleep(0.05)
raise RuntimeError("Server did not start in time")
def _start_server(app: FastAPI, host: str, port: int):
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
_wait_for_port(host, port)
return server, thread
def test_e2e_error_logging(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "size",
"log_rotation_max_bytes": 2048,
}
)
configure_logging(settings.model_copy(update={"runtime": runtime}))
app = FastAPI()
app.add_middleware(RequestContextMiddleware) # type: ignore[arg-type]
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> dict[str, str]:
raise RuntimeError("boom")
host = "127.0.0.1"
port = _find_free_port()
server, thread = _start_server(app, host, port)
try:
with sync_playwright() as playwright:
request_context = playwright.request.new_context(
base_url=f"http://{host}:{port}"
)
response = request_context.get(
"/boom",
headers={"X-Request-ID": "e2e-5000"},
)
assert response.status == 500
request_context.dispose()
finally:
server.should_exit = True
thread.join(timeout=5)
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
entry = next(
item for item in error_entries if item.get("message") == "Unhandled exception"
)
assert entry["request_id"] == "e2e-5000"
exception = str(entry["exception"])
assert "Traceback" in exception
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.config.settings import Settings
from core.logging.config import configure_logging
from core.logging.logger import get_logger
from core.logging.middleware import (
RequestContextMiddleware,
register_exception_handlers,
)
def _read_json_lines(path: Path) -> list[dict[str, object]]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def _configure_test_logging(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "size",
"log_rotation_max_bytes": 2048,
}
)
test_settings = settings.model_copy(update={"runtime": runtime})
configure_logging(test_settings)
def test_middleware_binds_request_context(tmp_path: Path) -> None:
_configure_test_logging(tmp_path)
app = FastAPI()
app.add_middleware(RequestContextMiddleware) # type: ignore[arg-type]
@app.get("/ok")
async def ok() -> dict[str, str]:
logger = get_logger("tests.ok")
logger.info("request accepted", context_key="context_value")
return {"status": "ok"}
client = TestClient(app)
response = client.get("/ok", headers={"X-Request-ID": "req-1234"})
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "req-1234"
log_entries = _read_json_lines(Path(tmp_path) / "app.log")
entry = next(
item for item in log_entries if item.get("message") == "request accepted"
)
assert entry["message"] == "request accepted"
assert entry["request_id"] == "req-1234"
assert entry["method"] == "GET"
assert entry["path"] == "/ok"
assert entry["context_key"] == "context_value"
logging.shutdown()
def test_exception_handler_logs_stack_and_sends_500(tmp_path: Path) -> None:
_configure_test_logging(tmp_path)
app = FastAPI()
app.add_middleware(RequestContextMiddleware)
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> dict[str, str]:
raise RuntimeError("boom")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/boom", headers={"X-Request-ID": "req-5000"})
assert response.status_code == 500
assert response.json()["detail"] == "Internal Server Error"
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
assert error_entries
entry = error_entries[-1]
assert entry["level"] == "error"
assert entry["request_id"] == "req-5000"
exception = str(entry["exception"])
assert "Traceback" in exception
assert "test_fastapi_logging_integration" in exception
logging.shutdown()
def test_invalid_request_id_is_replaced_and_used_in_error_context(
tmp_path: Path,
) -> None:
_configure_test_logging(tmp_path)
app = FastAPI()
app.add_middleware(RequestContextMiddleware)
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> dict[str, str]:
raise RuntimeError("boom")
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/boom", headers={"X-Request-ID": "bad"})
assert response.status_code == 500
response_request_id = response.headers["X-Request-ID"]
assert response_request_id != "bad"
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
assert error_entries
entry = error_entries[-1]
assert entry["request_id"] == response_request_id
exception = str(entry["exception"])
assert "Traceback" in exception
logging.shutdown()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from celery import Celery
from pytest import MonkeyPatch
from core.logging import celery as celery_logging
from core.logging.context import clear_context, get_context
class DummyTask:
name: str = "tasks.sample"
def test_celery_prerun_binds_task_context() -> None:
handlers = celery_logging.build_celery_signal_handlers()
handlers.on_task_prerun(task_id="task-123", task=DummyTask())
context = get_context()
assert context["task_id"] == "task-123"
assert context["task_name"] == "tasks.sample"
clear_context()
def test_celery_setup_logging_calls_configure(monkeypatch: MonkeyPatch) -> None:
called = {"value": False}
def fake_configure_logging(settings: object | None = None) -> None:
called["value"] = True
monkeypatch.setattr(celery_logging, "configure_logging", fake_configure_logging)
handlers = celery_logging.build_celery_signal_handlers()
handlers.on_setup_logging()
assert called["value"] is True
def test_configure_celery_app_disables_hijack() -> None:
app = Celery("test")
celery_logging.configure_celery_app(app)
assert app.conf.worker_hijack_root_logger is False
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import logging
from collections.abc import Iterator
from pathlib import Path
from typing import cast
import pytest
import structlog
from core.config.settings import Settings
from core.logging.config import build_logging_config, configure_logging
def _get_handlers(config: dict[str, object]) -> dict[str, dict[str, object]]:
return cast(dict[str, dict[str, object]], config["handlers"])
def test_build_logging_config_time_rotation(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "time",
}
)
config = build_logging_config(runtime)
handlers = _get_handlers(config)
assert handlers["file"]["class"] == "logging.handlers.TimedRotatingFileHandler"
assert handlers["error"]["class"] == "logging.handlers.TimedRotatingFileHandler"
assert handlers["error"]["level"] == "ERROR"
def test_build_logging_config_size_rotation(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "size",
"log_rotation_max_bytes": 2048,
}
)
config = build_logging_config(runtime)
handlers = _get_handlers(config)
assert handlers["file"]["class"] == "logging.handlers.RotatingFileHandler"
assert handlers["error"]["class"] == "logging.handlers.RotatingFileHandler"
assert handlers["file"]["maxBytes"] == 2048
def test_build_logging_config_plain_formatter_when_disabled(tmp_path: Path) -> None:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_json": False,
}
)
config = build_logging_config(runtime)
handlers = _get_handlers(config)
assert handlers["file"]["formatter"] == "plain"
assert handlers["error"]["formatter"] == "plain"
def _read_last_log_entry(log_path: Path) -> dict[str, object]:
assert log_path.exists(), f"Expected log file at {log_path}"
entries = [
json.loads(line) for line in log_path.read_text().splitlines() if line.strip()
]
assert entries, "Expected at least one log entry in app.log"
return entries[-1]
def _flush_root_handlers() -> None:
root_logger = logging.getLogger()
for handler in root_logger.handlers:
if hasattr(handler, "flush"):
handler.flush()
@pytest.fixture
def configured_logging(tmp_path: Path) -> Iterator[Path]:
settings = Settings()
runtime = settings.runtime.model_copy(
update={
"log_dir": str(tmp_path),
"log_error_dir": str(tmp_path / "errors"),
"log_rotation": "size",
"log_rotation_max_bytes": 2048,
"log_json": True,
}
)
root_logger = logging.getLogger()
original_handlers = root_logger.handlers[:]
original_level = root_logger.level
configure_logging(settings.model_copy(update={"runtime": runtime}))
yield tmp_path
for handler in root_logger.handlers:
handler.close()
root_logger.handlers = original_handlers
root_logger.setLevel(original_level)
structlog.reset_defaults()
def test_stdlib_logging_redacts_sensitive_fields(configured_logging: Path) -> None:
logger = logging.getLogger("tests.stdlib")
logger.info("login", extra={"password": "secret", "token": "abc"})
_flush_root_handlers()
log_path = configured_logging / "app.log"
entry = _read_last_log_entry(log_path)
assert entry["password"] == "[REDACTED]"
assert entry["token"] == "[REDACTED]"
def test_structlog_redacts_sensitive_fields(configured_logging: Path) -> None:
logger = structlog.get_logger("tests.structlog")
logger.info("login", password="secret", token="abc")
_flush_root_handlers()
log_path = configured_logging / "app.log"
entry = _read_last_log_entry(log_path)
assert entry["password"] == "[REDACTED]"
assert entry["token"] == "[REDACTED]"
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from core.logging.filters import build_sensitive_data_processor
def test_redact_sensitive_fields_masks_values() -> None:
processor = build_sensitive_data_processor(
["password", "token", "api_key", "cookie"]
)
event: dict[str, object] = {
"message": "login",
"password": "secret",
"access_token": "token-123",
"apiKey": "apikey-123",
"set-cookie": "cookie-1",
"nested": {"token": "abc", "safe": "ok"},
"list": [{"password": "x"}],
}
redacted = processor(None, "info", event)
assert redacted["password"] == "[REDACTED]"
assert redacted["access_token"] == "[REDACTED]"
assert redacted["apiKey"] == "[REDACTED]"
assert redacted["set-cookie"] == "[REDACTED]"
assert redacted["nested"]["token"] == "[REDACTED]"
assert redacted["nested"]["safe"] == "ok"
assert redacted["list"][0]["password"] == "[REDACTED]"
assert event["password"] == "secret"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from pytest import MonkeyPatch
from core.config.settings import Settings
def test_runtime_settings_defaults() -> None:
settings = Settings()
assert settings.runtime.log_json is True
assert settings.runtime.log_rotation == "time"
assert settings.runtime.log_rotation_when == "midnight"
assert settings.runtime.log_rotation_interval == 1
assert settings.runtime.log_rotation_backup_count == 14
assert settings.runtime.log_rotation_max_bytes == 10_000_000
assert settings.runtime.log_dir == "logs"
assert settings.runtime.log_error_dir == "logs/errors"
assert settings.runtime.log_file_name == "app.log"
assert settings.runtime.log_error_file_name == "error.log"
assert "password" in settings.runtime.log_sensitive_fields
def test_runtime_settings_env_override(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_DIR", "var/logs")
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ERROR_DIR", "var/logs/errors")
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ROTATION", "size")
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ROTATION_MAX_BYTES", "2048")
settings = Settings()
assert settings.runtime.log_dir == "var/logs"
assert settings.runtime.log_error_dir == "var/logs/errors"
assert settings.runtime.log_rotation == "size"
assert settings.runtime.log_rotation_max_bytes == 2048