cognee/cognee/shared/logging_utils.py

591 lines
22 KiB
Python

import os
import sys
import logging
import tempfile
import structlog
import traceback
import platform
from datetime import datetime
from pathlib import Path
import importlib.metadata
from cognee import __version__ as cognee_version
from typing import Protocol
# Configure external library logging
def configure_external_library_logging():
"""Configure logging for external libraries to reduce verbosity"""
# Set environment variables to suppress LiteLLM logging
os.environ.setdefault("LITELLM_LOG", "ERROR")
os.environ.setdefault("LITELLM_SET_VERBOSE", "False")
# Configure LiteLLM logging to reduce verbosity
try:
import litellm
# Disable verbose logging
litellm.set_verbose = False
# Set additional LiteLLM configuration
if hasattr(litellm, "suppress_debug_info"):
litellm.suppress_debug_info = True
if hasattr(litellm, "turn_off_message"):
litellm.turn_off_message = True
if hasattr(litellm, "_turn_on_debug"):
litellm._turn_on_debug = False
# Comprehensive logger suppression
loggers_to_suppress = [
"litellm",
"litellm.litellm_core_utils.logging_worker",
"litellm.litellm_core_utils",
"litellm.proxy",
"litellm.router",
"openai._base_client",
"LiteLLM", # Capital case variant
"LiteLLM.core",
"LiteLLM.logging_worker",
"litellm.logging_worker",
]
for logger_name in loggers_to_suppress:
logging.getLogger(logger_name).setLevel(logging.CRITICAL)
logging.getLogger(logger_name).disabled = True
except ImportError:
# LiteLLM not available, skip configuration
pass
# Export common log levels
DEBUG = logging.DEBUG
INFO = logging.INFO
WARNING = logging.WARNING
ERROR = logging.ERROR
CRITICAL = logging.CRITICAL
log_levels = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
"NOTSET": logging.NOTSET,
}
# Track if structlog logging has been configured
_is_structlog_configured = False
def resolve_logs_dir():
"""Resolve a writable logs directory.
Priority:
1) BaseConfig.logs_root_directory (respects COGNEE_LOGS_DIR)
2) /tmp/cognee_logs (default, best-effort create)
Returns a Path or None if none are writable/creatable.
"""
from cognee.base_config import get_base_config
base_config = get_base_config()
logs_root_directory = Path(base_config.logs_root_directory)
try:
logs_root_directory.mkdir(parents=True, exist_ok=True)
if os.access(logs_root_directory, os.W_OK):
return logs_root_directory
except Exception:
pass
try:
tmp_log_path = Path(os.path.join("/tmp", "cognee_logs"))
tmp_log_path.mkdir(parents=True, exist_ok=True)
if os.access(tmp_log_path, os.W_OK):
return tmp_log_path
except Exception:
pass
return None
# Maximum number of log files to keep
MAX_LOG_FILES = 10
# Version information
PYTHON_VERSION = platform.python_version()
STRUCTLOG_VERSION = structlog.__version__
COGNEE_VERSION = cognee_version
OS_INFO = f"{platform.system()} {platform.release()} ({platform.version()})"
class PlainFileHandler(logging.FileHandler):
"""A custom file handler that writes simpler plain text log entries."""
def emit(self, record):
try:
# Check if stream is available before trying to write
if self.stream is None:
self.stream = self._open()
# Extract the message from the structlog record
if isinstance(record.msg, dict) and "event" in record.msg:
# Extract the basic message
message = record.msg.get("event", "")
# Extract additional context
context = {
k: v
for k, v in record.msg.items()
if k not in ("event", "logger", "level", "timestamp")
}
# Format context if present
context_str = ""
if context:
context_str = " " + " ".join(
f"{k}={v}" for k, v in context.items() if k != "exc_info"
)
# Get the logger name from the record or from the structlog context
logger_name = record.msg.get("logger", record.name)
# Format timestamp
timestamp = datetime.now().strftime(get_timestamp_format())
# Create the log entry
log_entry = f"{timestamp} [{record.levelname.ljust(8)}] {message}{context_str} [{logger_name}]\n"
# Write to file
self.stream.write(log_entry)
self.flush()
# Handle exception if present
# Check both record.exc_info and the 'exc_info' in the message dict
record_has_exc = record.exc_info and record.exc_info != (None, None, None)
msg_has_exc = "exc_info" in record.msg and record.msg["exc_info"]
if record_has_exc:
# Use the exception info from the record
tb_str = "".join(traceback.format_exception(*record.exc_info))
self.stream.write(tb_str + "\n")
self.flush()
elif msg_has_exc and isinstance(record.msg["exc_info"], tuple):
# Use the exception info from the message
tb_str = "".join(traceback.format_exception(*record.msg["exc_info"]))
self.stream.write(tb_str + "\n")
self.flush()
elif msg_has_exc and hasattr(record.msg["exc_info"], "__traceback__"):
# Handle exceptions that are passed directly
exc = record.msg["exc_info"]
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
self.stream.write(tb_str + "\n")
self.flush()
else:
# Fall back to standard handling for non-structlog messages
msg = self.format(record)
self.stream.write(msg + self.terminator)
self.flush()
# Handle exception if present in regular record
if record.exc_info and record.exc_info != (None, None, None):
tb_str = "".join(traceback.format_exception(*record.exc_info))
self.stream.write(tb_str + "\n")
self.flush()
except Exception as e:
self.handleError(record)
# Write error about handling this record
self.stream.write(f"Error in log handler: {e}\n")
self.flush()
class LoggerInterface(Protocol):
def info(self, msg: str, *args, **kwargs) -> None: ...
def warning(self, msg: str, *args, **kwargs) -> None: ...
def error(self, msg: str, *args, **kwargs) -> None: ...
def critical(self, msg: str, *args, **kwargs) -> None: ...
def debug(self, msg: str, *args, **kwargs) -> None: ...
def get_logger(name=None, level=None) -> LoggerInterface:
"""Get a logger.
If `setup_logging()` has not been called, returns a standard Python logger.
If `setup_logging()` has been called, returns a structlog logger.
"""
if _is_structlog_configured:
return structlog.get_logger(name if name else __name__)
else:
logger = logging.getLogger(name if name else __name__)
if level is not None:
logger.setLevel(level)
return logger
def log_database_configuration(logger):
"""Log the current database configuration for all database types"""
# NOTE: Has to be imporated at runtime to avoid circular import
from cognee.infrastructure.databases.relational.config import get_relational_config
from cognee.infrastructure.databases.vector.config import get_vectordb_config
from cognee.infrastructure.databases.graph.config import get_graph_config
try:
# Get base database directory path
from cognee.base_config import get_base_config
base_config = get_base_config()
databases_path = os.path.join(base_config.system_root_directory, "databases")
# Log concise database info
logger.info(f"Database storage: {databases_path}")
except Exception as e:
logger.debug(f"Could not retrieve database configuration: {str(e)}")
def cleanup_old_logs(logs_dir, max_files):
"""
Removes old log files, keeping only the most recent ones.
Args:
logs_dir: Directory containing log files
max_files: Maximum number of log files to keep
"""
logger = structlog.get_logger()
try:
# Get all .log files in the directory (excluding README and other files)
log_files = [f for f in logs_dir.glob("*.log") if f.is_file()]
# Sort log files by modification time (newest first)
log_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
# Remove old files that exceed the maximum
if len(log_files) > max_files:
deleted_count = 0
for old_file in log_files[max_files:]:
try:
old_file.unlink()
deleted_count += 1
# Only log individual files in non-CLI mode
if os.getenv("COGNEE_CLI_MODE") != "true":
logger.info(f"Deleted old log file: {old_file}")
except Exception as e:
# Always log errors
logger.error(f"Failed to delete old log file {old_file}: {e}")
# In CLI mode, show compact summary
if os.getenv("COGNEE_CLI_MODE") == "true" and deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} old log files")
return True
except Exception as e:
logger.error(f"Error cleaning up log files: {e}")
return False
def setup_logging(log_level=None, name=None):
"""Sets up the logging configuration with structlog integration.
Args:
log_level: The logging level to use (default: None, uses INFO)
name: Optional logger name (default: None, uses __name__)
Returns:
A configured structlog logger instance
"""
global _is_structlog_configured
# Regular detailed logging for non-CLI usage
log_level = log_level if log_level else log_levels[os.getenv("LOG_LEVEL", "INFO").upper()]
# Configure external library logging early to suppress verbose output
configure_external_library_logging()
# Add custom filter to suppress LiteLLM worker cancellation errors
class LiteLLMCancellationFilter(logging.Filter):
"""Filter to suppress LiteLLM worker cancellation messages"""
def filter(self, record):
# Check if this is a LiteLLM-related logger
if hasattr(record, "name") and "litellm" in record.name.lower():
return False
# Check message content for cancellation errors
if hasattr(record, "msg") and record.msg:
msg_str = str(record.msg).lower()
if any(
keyword in msg_str
for keyword in [
"loggingworker cancelled",
"logging_worker.py",
"cancellederror",
"litellm:error",
]
):
return False
# Check formatted message
try:
if hasattr(record, "getMessage"):
formatted_msg = record.getMessage().lower()
if any(
keyword in formatted_msg
for keyword in [
"loggingworker cancelled",
"logging_worker.py",
"cancellederror",
"litellm:error",
]
):
return False
except Exception:
pass
return True
# Apply the filter to root logger and specific loggers
cancellation_filter = LiteLLMCancellationFilter()
logging.getLogger().addFilter(cancellation_filter)
logging.getLogger("litellm").addFilter(cancellation_filter)
# Add custom filter to suppress LiteLLM worker cancellation errors
class LiteLLMFilter(logging.Filter):
def filter(self, record):
# Suppress LiteLLM worker cancellation errors
if hasattr(record, "msg") and isinstance(record.msg, str):
msg_lower = record.msg.lower()
if any(
phrase in msg_lower
for phrase in [
"loggingworker cancelled",
"cancellederror",
"logging_worker.py",
"loggingerror",
]
):
return False
return True
# Apply filter to root logger
litellm_filter = LiteLLMFilter()
logging.getLogger().addFilter(litellm_filter)
def exception_handler(logger, method_name, event_dict):
"""Custom processor to handle uncaught exceptions."""
# Check if there's an exc_info that needs to be processed
if event_dict.get("exc_info"):
# If it's already a tuple, use it directly
if isinstance(event_dict["exc_info"], tuple):
exc_type, exc_value, tb = event_dict["exc_info"]
else:
exc_type, exc_value, tb = sys.exc_info()
if hasattr(exc_type, __name__):
event_dict["exception_type"] = exc_type.__name__
event_dict["exception_message"] = str(exc_value)
event_dict["traceback"] = True
return event_dict
# Configure structlog
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt=get_timestamp_format(), utc=True),
structlog.processors.StackInfoRenderer(),
exception_handler, # Add our custom exception handler
structlog.processors.UnicodeDecoder(),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# Set up system-wide exception handling
def handle_exception(exc_type, exc_value, traceback):
"""Handle any uncaught exception."""
if issubclass(exc_type, KeyboardInterrupt):
# Let KeyboardInterrupt pass through
sys.__excepthook__(exc_type, exc_value, traceback)
return
logger = structlog.get_logger()
logger.error(
"Exception",
exc_info=(exc_type, exc_value, traceback),
)
# Hand back to the original hook → prints traceback and exits
sys.__excepthook__(exc_type, exc_value, traceback)
# Install exception handlers
sys.excepthook = handle_exception
# Create console formatter for standard library logging
console_formatter = structlog.stdlib.ProcessorFormatter(
processor=structlog.dev.ConsoleRenderer(
colors=True,
force_colors=True,
level_styles={
"critical": structlog.dev.RED,
"exception": structlog.dev.RED,
"error": structlog.dev.RED,
"warn": structlog.dev.YELLOW,
"warning": structlog.dev.YELLOW,
"info": structlog.dev.GREEN,
"debug": structlog.dev.BLUE,
},
),
)
# Setup handler with newlines for console output
class NewlineStreamHandler(logging.StreamHandler):
def emit(self, record):
try:
msg = self.format(record)
stream = self.stream
if hasattr(stream, "closed") and stream.closed:
return
stream.write("\n" + msg + self.terminator)
self.flush()
except Exception:
self.handleError(record)
# Use our custom handler for console output
stream_handler = NewlineStreamHandler(sys.stderr)
stream_handler.setFormatter(console_formatter)
stream_handler.setLevel(log_level)
root_logger = logging.getLogger()
if root_logger.hasHandlers():
root_logger.handlers.clear()
root_logger.addHandler(stream_handler)
# Note: root logger needs to be set at NOTSET to allow all messages through and specific stream and file handlers
# can define their own levels.
root_logger.setLevel(logging.NOTSET)
# Resolve logs directory with env and safe fallbacks
logs_dir = resolve_logs_dir()
# Check if we already have a log file path from the environment
# NOTE: environment variable must be used here as it allows us to
# log to a single file with a name based on a timestamp in a multiprocess setting.
# Without it, we would have a separate log file for every process.
log_file_path = os.environ.get("LOG_FILE_NAME")
if not log_file_path and logs_dir is not None:
# Create a new log file name with the cognee start time
start_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_file_path = str((logs_dir / f"{start_time}.log").resolve())
os.environ["LOG_FILE_NAME"] = log_file_path
try:
# Create a file handler that uses our custom PlainFileHandler
file_handler = PlainFileHandler(log_file_path, encoding="utf-8")
file_handler.setLevel(DEBUG)
root_logger.addHandler(file_handler)
except Exception as e:
# Note: Exceptions happen in case of read only file systems or log file path poiting to location where it does
# not have write permission. Logging to file is not mandatory so we just log a warning to console.
root_logger.warning(f"Warning: Could not create log file handler at {log_file_path}: {e}")
if log_level > logging.DEBUG:
import warnings
from sqlalchemy.exc import SAWarning
warnings.filterwarnings(
"ignore", category=SAWarning, module="dlt.destinations.impl.sqlalchemy.merge_job"
)
warnings.filterwarnings(
"ignore", category=SAWarning, module="dlt.destinations.impl.sqlalchemy.load_jobs"
)
# Clean up old log files, keeping only the most recent ones
if logs_dir is not None:
cleanup_old_logs(logs_dir, MAX_LOG_FILES)
# Mark logging as configured
_is_structlog_configured = True
from cognee.infrastructure.databases.relational.config import get_relational_config
from cognee.infrastructure.databases.vector.config import get_vectordb_config
from cognee.infrastructure.databases.graph.config import get_graph_config
graph_config = get_graph_config()
vector_config = get_vectordb_config()
relational_config = get_relational_config()
try:
# Get base database directory path
from cognee.base_config import get_base_config
base_config = get_base_config()
databases_path = os.path.join(base_config.system_root_directory, "databases")
except Exception as e:
raise ValueError from e
# Get a configured logger and log system information
logger = structlog.get_logger(name if name else __name__)
logger.warning(
"From version 0.5.0 onwards, Cognee will run with multi-user access control mode set to on by default. Data isolation between different users and datasets will be enforced and data created before multi-user access control mode was turned on won't be accessible by default. To disable multi-user access control mode and regain access to old data set the environment variable ENABLE_BACKEND_ACCESS_CONTROL to false before starting Cognee. For more information, please refer to the Cognee documentation."
)
if logs_dir is not None:
logger.info(f"Log file created at: {log_file_path}", log_file=log_file_path)
# Detailed initialization for regular usage
logger.info(
"Logging initialized",
python_version=PYTHON_VERSION,
structlog_version=STRUCTLOG_VERSION,
cognee_version=COGNEE_VERSION,
os_info=OS_INFO,
database_path=databases_path,
graph_database_name=graph_config.graph_database_name,
vector_config=vector_config.vector_db_provider,
relational_config=relational_config.db_name,
)
# Log database configuration
log_database_configuration(logger)
# Return the configured logger
return logger
def get_log_file_location():
"""Return the file path of the log file in use, if any."""
root_logger = logging.getLogger()
# Loop through handlers to find the FileHandler
for handler in root_logger.handlers:
if isinstance(handler, logging.FileHandler):
return handler.baseFilename
def get_timestamp_format():
# NOTE: Some users have complained that Cognee crashes when trying to get microsecond value
# Added handler to not use microseconds if users can't access it
logger = structlog.get_logger()
try:
# We call datetime.now() here to test if microseconds are supported.
# If they are not supported a ValueError will be raised
datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")
return "%Y-%m-%dT%H:%M:%S.%f"
except Exception as e:
logger.debug(f"Exception caught: {e}")
logger.debug(
"Could not use microseconds for the logging timestamp, defaulting to use hours minutes and seconds only"
)
# We call datetime.now() here to test if won't break.
datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
# We return the timestamp format without microseconds as they are not supported
return "%Y-%m-%dT%H:%M:%S"