Files
letta-server/letta/log.py
Kian Jones 1577a261d8 feat: add profiling and structured logging (#5690)
* test dd build

* dd agent in cluster

* quick poc

* refactor and add logging

* remove tracing etc.

* add changes to otel logging config

* refactor to accept my feedback

* finishing touches
2025-10-24 15:14:20 -07:00

241 lines
8.4 KiB
Python

import json
import logging
import traceback
from datetime import datetime, timezone
from logging.config import dictConfig
from pathlib import Path
from sys import stdout
from typing import Any, Optional
from letta.settings import log_settings, settings, telemetry_settings
selected_log_level = logging.DEBUG if settings.debug else logging.INFO
class JSONFormatter(logging.Formatter):
"""
Custom JSON formatter for structured logging with Datadog integration.
Outputs logs in JSON format with fields compatible with Datadog log ingestion.
Automatically includes trace correlation fields when Datadog tracing is enabled.
Usage:
Enable JSON logging by setting the environment variable:
LETTA_LOGGING_JSON_LOGGING=true
Add custom structured fields to logs using the 'extra' parameter:
logger.info("User action", extra={"user_id": "123", "action": "login"})
These fields will be automatically included in the JSON output and
indexed by Datadog for filtering and analysis.
Output format:
{
"timestamp": "2025-10-23T18:34:24.931739+00:00",
"level": "INFO",
"logger": "Letta.module",
"message": "Log message",
"module": "module_name",
"function": "function_name",
"line": 123,
"dd.trace_id": "1234567890", # Added when Datadog tracing is enabled
"dd.span_id": "9876543210", # Added when Datadog tracing is enabled
"custom_field": "custom_value" # Any extra fields you provide
}
"""
def format(self, record: logging.LogRecord) -> str:
"""Format log record as JSON with Datadog-compatible fields."""
# Base log structure
log_data: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# Add Datadog trace correlation if available
# ddtrace automatically injects these attributes when logging is patched
if hasattr(record, "dd.trace_id"):
log_data["dd.trace_id"] = getattr(record, "dd.trace_id")
if hasattr(record, "dd.span_id"):
log_data["dd.span_id"] = getattr(record, "dd.span_id")
if hasattr(record, "dd.service"):
log_data["dd.service"] = getattr(record, "dd.service")
if hasattr(record, "dd.env"):
log_data["dd.env"] = getattr(record, "dd.env")
if hasattr(record, "dd.version"):
log_data["dd.version"] = getattr(record, "dd.version")
# Add exception info if present
if record.exc_info:
log_data["exception"] = {
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
"stacktrace": "".join(traceback.format_exception(*record.exc_info)),
}
# Add any extra fields from the log record
# These are custom fields passed via logging.info("msg", extra={...})
for key, value in record.__dict__.items():
if key not in [
"name",
"msg",
"args",
"created",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"module",
"msecs",
"message",
"pathname",
"process",
"processName",
"relativeCreated",
"thread",
"threadName",
"exc_info",
"exc_text",
"stack_info",
"dd_env",
"dd_service",
] and not key.startswith("dd."):
log_data[key] = value
return json.dumps(log_data, default=str)
class DatadogEnvFilter(logging.Filter):
"""
Logging filter that adds Datadog-specific attributes to log records.
This enables log-trace correlation by injecting environment and service metadata
that Datadog can use to link logs with traces and other telemetry data.
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Add Datadog attributes to log record if Datadog is enabled."""
if telemetry_settings.enable_datadog:
record.dd_env = telemetry_settings.datadog_env
record.dd_service = "letta-server"
else:
# Provide defaults to prevent attribute errors if filter is applied incorrectly
record.dd_env = ""
record.dd_service = ""
return True
def _setup_logfile() -> "Path":
"""ensure the logger filepath is in place
Returns: the logfile Path
"""
logfile = Path(settings.letta_dir / "logs" / "Letta.log")
logfile.parent.mkdir(parents=True, exist_ok=True)
logfile.touch(exist_ok=True)
return logfile
# Determine which formatter to use based on configuration
def _get_console_formatter() -> str:
"""Determine the appropriate console formatter based on settings."""
if log_settings.json_logging:
return "json"
elif telemetry_settings.enable_datadog:
return "datadog"
else:
return "no_datetime"
def _get_file_formatter() -> str:
"""Determine the appropriate file formatter based on settings."""
if log_settings.json_logging:
return "json"
elif telemetry_settings.enable_datadog:
return "datadog"
else:
return "standard"
# Logging configuration with optional Datadog integration and JSON support
DEVELOPMENT_LOGGING = {
"version": 1,
"disable_existing_loggers": False, # Allow capturing from all loggers
"formatters": {
"standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"},
"no_datetime": {"format": "%(name)s - %(levelname)s - %(message)s"},
"datadog": {
# Datadog-compatible format with key=value pairs for better parsing
# ddtrace's log injection will add dd.trace_id, dd.span_id automatically when logging is patched
"format": "%(asctime)s - %(name)s - %(levelname)s - [dd.env=%(dd_env)s dd.service=%(dd_service)s] - %(message)s"
},
"json": {
# JSON formatter for structured logging with full Datadog integration
"()": JSONFormatter,
},
},
"filters": {
"datadog_env": {
"()": DatadogEnvFilter,
},
},
"handlers": {
"console": {
"level": selected_log_level,
"class": "logging.StreamHandler",
"stream": stdout,
"formatter": _get_console_formatter(),
"filters": ["datadog_env"] if telemetry_settings.enable_datadog and not log_settings.json_logging else [],
},
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": _setup_logfile(),
"maxBytes": 1024**2 * 10, # 10 MB per file
"backupCount": 3, # Keep 3 backup files
"formatter": _get_file_formatter(),
"filters": ["datadog_env"] if telemetry_settings.enable_datadog and not log_settings.json_logging else [],
},
},
"root": { # Root logger handles all logs
"level": logging.DEBUG if settings.debug else logging.INFO,
"handlers": ["console", "file"],
},
"loggers": {
"Letta": {
"level": logging.DEBUG if settings.debug else logging.INFO,
"propagate": True, # Let logs bubble up to root
},
"uvicorn": {
"level": "CRITICAL",
"handlers": ["console"],
"propagate": True,
},
# Reduce noise from ddtrace internal logging
"ddtrace": {
"level": "WARNING",
"propagate": True,
},
},
}
# Configure logging once at module initialization to avoid performance overhead
dictConfig(DEVELOPMENT_LOGGING)
def get_logger(name: Optional[str] = None) -> "logging.Logger":
"""returns the project logger, scoped to a child name if provided
Args:
name: will define a child logger
"""
parent_logger = logging.getLogger("Letta")
if name:
return parent_logger.getChild(name)
return parent_logger