Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/backend/tests/unit/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
setup_gunicorn_logger,
setup_uvicorn_logger,
)
from loguru import logger as loguru_logger


class TestConfigure:
Expand Down Expand Up @@ -104,6 +105,29 @@ def test_configure_with_log_file(self):
if isinstance(handler, logging.handlers.RotatingFileHandler):
logging.root.removeHandler(handler)

def test_configure_routes_loguru_messages_to_log_file(self):
"""Test configure() routes Loguru messages through Langflow logging."""
with tempfile.TemporaryDirectory() as tmp_dir:
log_file_path = Path(tmp_dir) / "langflow.log"

for handler in logging.root.handlers[:]:
if isinstance(handler, logging.handlers.RotatingFileHandler):
logging.root.removeHandler(handler)

try:
configure(log_level="INFO", log_file=log_file_path, cache=False)
loguru_logger.info("Custom component log message")

for handler in logging.root.handlers:
if hasattr(handler, "flush"):
handler.flush()

assert "Custom component log message" in log_file_path.read_text()
finally:
for handler in logging.root.handlers[:]:
if isinstance(handler, logging.handlers.RotatingFileHandler):
logging.root.removeHandler(handler)

def test_configure_with_invalid_log_file_path(self):
"""Test configure() with invalid log file path falls back to cache dir."""
invalid_path = Path("/nonexistent/directory/log.txt")
Expand Down
65 changes: 55 additions & 10 deletions src/lfx/src/lfx/log/logger.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Logging configuration for Langflow using structlog."""

import contextlib
import json
import logging
import logging.handlers
Expand All @@ -13,6 +14,7 @@

import orjson
import structlog
from loguru import logger as loguru_logger
from platformdirs import user_cache_dir
from typing_extensions import NotRequired

Expand Down Expand Up @@ -158,6 +160,8 @@ def max_size(self) -> int:

# log buffer for capturing log messages
log_buffer = SizedLogBuffer()
_file_handler: logging.handlers.RotatingFileHandler | None = None
_loguru_handler_id: int | None = None


def add_serialized(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -192,6 +196,54 @@ def buffer_writer(_logger: Any, _method_name: str, event_dict: dict[str, Any]) -
return event_dict


def _forward_loguru_message(message) -> None:
"""Forward Loguru messages through Langflow's configured structlog pipeline."""
record = message.record
structlog_logger = structlog.get_logger(record["name"])
level_name = record["level"].name.lower()
log_method = getattr(structlog_logger, level_name, structlog_logger.info)
if record["exception"]:
log_method(record["message"], exc_info=record["exception"])
else:
log_method(record["message"])


def setup_loguru_logger(log_level: str, *, enqueue: bool = False) -> None:
"""Route Loguru's default logger through Langflow logging."""
global _loguru_handler_id # noqa: PLW0603

if _loguru_handler_id is not None:
with contextlib.suppress(ValueError):
loguru_logger.remove(_loguru_handler_id)
else:
with contextlib.suppress(ValueError):
loguru_logger.remove(0)

_loguru_handler_id = loguru_logger.add(
_forward_loguru_message,
level=log_level.upper(),
enqueue=enqueue,
format="{message}",
)


def setup_log_file(log_file: Path, *, max_bytes: int) -> None:
"""Set up Langflow's rotating file handler."""
global _file_handler # noqa: PLW0603

if _file_handler is not None:
logging.root.removeHandler(_file_handler)
_file_handler.close()

_file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=5,
)
_file_handler.setFormatter(logging.Formatter("%(message)s"))
logging.root.addHandler(_file_handler)


class LogConfig(TypedDict):
"""Configuration for logging."""

Expand Down Expand Up @@ -229,7 +281,7 @@ def configure(
if current_min_level == requested_min_level:
return

if log_level is None:
if log_level is None or log_level.upper() not in LOG_LEVEL_MAP:
log_level = "ERROR"

if log_file is None:
Expand Down Expand Up @@ -338,15 +390,7 @@ def configure(
max_bytes = 10 * 1024 * 1024 # Default 10MB

# Since structlog doesn't have built-in rotation, we'll use stdlib logging for file output
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=5,
)
file_handler.setFormatter(logging.Formatter("%(message)s"))

# Add file handler to root logger
logging.root.addHandler(file_handler)
setup_log_file(log_file, max_bytes=max_bytes)
logging.root.setLevel(numeric_level)

# Set up interceptors for uvicorn and gunicorn
Expand All @@ -356,6 +400,7 @@ def configure(
# Create the global logger instance
global logger # noqa: PLW0603
logger = structlog.get_logger()
setup_loguru_logger(log_level)

if disable:
# In structlog, we can set a very high filter level to effectively disable logging
Expand Down
Loading