chore: Cloud compatibility fixes and performance improvements (#454)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2025-12-15 20:07:55 -06:00
committed by GitHub
parent 126c0495c0
commit 78673d8e51
18 changed files with 568 additions and 247 deletions
+65 -61
View File
@@ -5,9 +5,9 @@ import os
import logging
import re
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Protocol, Union, runtime_checkable, List
from typing import Protocol, Union, runtime_checkable, List
from loguru import logger
from unidecode import unidecode
@@ -203,29 +203,35 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
def setup_logging(
env: str,
home_dir: Path,
log_file: Optional[str] = None,
log_level: str = "INFO",
console: bool = True,
log_to_file: bool = False,
log_to_stdout: bool = False,
structured_context: bool = False,
) -> None: # pragma: no cover
"""
Configure logging for the application.
"""Configure logging with explicit settings.
This function provides a simple, explicit interface for configuring logging.
Each entry point (CLI, MCP, API) should call this with appropriate settings.
Args:
env: The environment name (dev, test, prod)
home_dir: The root directory for the application
log_file: The name of the log file to write to
log_level: The logging level to use
console: Whether to log to the console
log_level: DEBUG, INFO, WARNING, ERROR
log_to_file: Write to ~/.basic-memory/basic-memory.log with rotation
log_to_stdout: Write to stderr (for Docker/cloud deployments)
structured_context: Bind tenant_id, fly_region, etc. for cloud observability
"""
# Remove default handler and any existing handlers
logger.remove()
# Add file handler if we are not running tests and a log file is specified
if log_file and env != "test":
# Setup file logger
log_path = home_dir / log_file
# In test mode, only log to stdout regardless of settings
env = os.getenv("BASIC_MEMORY_ENV", "dev")
if env == "test":
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
return
# Add file handler with rotation
if log_to_file:
log_path = Path.home() / ".basic-memory" / "basic-memory.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
logger.add(
str(log_path),
level=log_level,
@@ -233,42 +239,28 @@ def setup_logging(
retention="10 days",
backtrace=True,
diagnose=True,
enqueue=True,
enqueue=True, # Thread-safe async logging
colorize=False,
)
# Add console logger if requested or in test mode
if env == "test" or console:
# Add stdout handler (for Docker/cloud)
if log_to_stdout:
logger.add(sys.stderr, level=log_level, backtrace=True, diagnose=True, colorize=True)
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
# Bind environment context for structured logging (works in both local and cloud)
tenant_id = os.getenv("BASIC_MEMORY_TENANT_ID", "local")
fly_app_name = os.getenv("FLY_APP_NAME", "local")
fly_machine_id = os.getenv("FLY_MACHINE_ID", "local")
fly_region = os.getenv("FLY_REGION", "local")
logger.configure(
extra={
"tenant_id": tenant_id,
"fly_app_name": fly_app_name,
"fly_machine_id": fly_machine_id,
"fly_region": fly_region,
}
)
# Bind structured context for cloud observability
if structured_context:
logger.configure(
extra={
"tenant_id": os.getenv("BASIC_MEMORY_TENANT_ID", "local"),
"fly_app_name": os.getenv("FLY_APP_NAME", "local"),
"fly_machine_id": os.getenv("FLY_MACHINE_ID", "local"),
"fly_region": os.getenv("FLY_REGION", "local"),
}
)
# Reduce noise from third-party libraries
noisy_loggers = {
# HTTP client logs
"httpx": logging.WARNING,
# File watching logs
"watchfiles.main": logging.WARNING,
}
# Set log levels for noisy loggers
for logger_name, level in noisy_loggers.items():
logging.getLogger(logger_name).setLevel(level)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
@@ -337,7 +329,7 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
This function normalizes file paths to help detect potential conflicts:
- Converts to lowercase for case-insensitive comparison
- Normalizes Unicode characters
- Handles path separators consistently
- Converts backslashes to forward slashes for cross-platform consistency
Args:
file_path: The file path to normalize
@@ -346,19 +338,15 @@ def normalize_file_path_for_comparison(file_path: str) -> str:
Normalized file path for comparison purposes
"""
import unicodedata
from pathlib import PureWindowsPath
# Convert to lowercase for case-insensitive comparison
normalized = file_path.lower()
# Use PureWindowsPath to ensure backslashes are treated as separators
# regardless of current platform, then convert to POSIX-style
normalized = PureWindowsPath(file_path).as_posix().lower()
# Normalize Unicode characters (NFD normalization)
normalized = unicodedata.normalize("NFD", normalized)
# Replace path separators with forward slashes
normalized = normalized.replace("\\", "/")
# Remove multiple slashes
normalized = re.sub(r"/+", "/", normalized)
return normalized
@@ -442,21 +430,37 @@ def validate_project_path(path: str, project_path: Path) -> bool:
return False
def ensure_timezone_aware(dt: datetime) -> datetime:
"""Ensure a datetime is timezone-aware using system timezone.
def ensure_timezone_aware(dt: datetime, cloud_mode: bool | None = None) -> datetime:
"""Ensure a datetime is timezone-aware.
If the datetime is naive, convert it to timezone-aware using the system's local timezone.
If it's already timezone-aware, return it unchanged.
If the datetime is naive, convert it to timezone-aware. The interpretation
depends on cloud_mode:
- In cloud mode (PostgreSQL/asyncpg): naive datetimes are interpreted as UTC
- In local mode (SQLite): naive datetimes are interpreted as local time
asyncpg uses binary protocol which returns timestamps in UTC but as naive
datetimes. In cloud deployments, cloud_mode=True handles this correctly.
Args:
dt: The datetime to ensure is timezone-aware
cloud_mode: Optional explicit cloud_mode setting. If None, loads from config.
Returns:
A timezone-aware datetime
"""
if dt.tzinfo is None:
# Naive datetime - assume it's in local time and add timezone
return dt.astimezone()
# Determine cloud_mode: use explicit parameter if provided, otherwise load from config
if cloud_mode is None:
from basic_memory.config import ConfigManager
cloud_mode = ConfigManager().config.cloud_mode_enabled
if cloud_mode:
# Cloud/PostgreSQL mode: naive datetimes from asyncpg are already UTC
return dt.replace(tzinfo=timezone.utc)
else:
# Local/SQLite mode: naive datetimes are in local time
return dt.astimezone()
else:
# Already timezone-aware
return dt