diff --git a/core/src/agentforge_core/observability/logging.py b/core/src/agentforge_core/observability/logging.py new file mode 100644 index 0000000..6b3a5f6 --- /dev/null +++ b/core/src/agentforge_core/observability/logging.py @@ -0,0 +1,43 @@ +"""Configuración de logging estructurado JSON con propagación de trace_id.""" + +from __future__ import annotations + +import logging +import sys + +import structlog +from structlog.contextvars import bind_contextvars, clear_contextvars + + +def configure_logging(level: str = "INFO") -> None: + """Configura structlog para emitir JSON a stdout, una línea por evento.""" + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, level), + force=True, + ) + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, level)), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + +def bind_trace_id(trace_id: str) -> None: + """Añade trace_id al contexto actual; aparece en todos los logs siguientes.""" + bind_contextvars(trace_id=trace_id) + + +def clear_trace_id() -> None: + """Limpia el contexto al final del request.""" + clear_contextvars() diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..e0816a9 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,30 @@ +"""Tests de logging estructurado.""" + +from __future__ import annotations + +import json + +import structlog + +from agentforge_core.observability.logging import bind_trace_id, configure_logging + + +def test_configure_logging_emite_json(capsys) -> None: # type: ignore[no-untyped-def] + configure_logging(level="INFO") + log = structlog.get_logger() + log.info("hola", clave="valor") + captured = capsys.readouterr() + line = captured.out.strip().splitlines()[-1] + parsed = json.loads(line) + assert parsed["event"] == "hola" + assert parsed["clave"] == "valor" + + +def test_bind_trace_id_aparece_en_logs(capsys) -> None: # type: ignore[no-untyped-def] + configure_logging(level="INFO") + bind_trace_id("trace-abc-123") + log = structlog.get_logger() + log.info("evento") + line = capsys.readouterr().out.strip().splitlines()[-1] + parsed = json.loads(line) + assert parsed["trace_id"] == "trace-abc-123"