Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
811f8a2
Create one place where logging is setup
samdoran May 4, 2026
cbc1225
Add a helper function to correctly construct the value that is usuall…
samdoran May 4, 2026
bf630dc
Get logger using __file__ instead of __name__
samdoran May 4, 2026
0614304
Pass logging config to uvicorn
samdoran May 4, 2026
04dc03a
Fine tune default log format
samdoran May 4, 2026
6d8ce69
Make logging config work with rich and uvicorn
samdoran May 5, 2026
93054fc
Use __name__
samdoran May 6, 2026
a3b5a26
Change default logger name
samdoran May 6, 2026
2fb0925
Go back to manually setting the logger name
samdoran May 8, 2026
9d3dfe4
Update tests
samdoran May 8, 2026
35c2d1e
Add type hint
samdoran May 11, 2026
5802100
Add custom formatter for RichHandler to output miliseconds
samdoran May 11, 2026
db4b638
Update doc string with new parameter
samdoran May 11, 2026
066df25
Merge config into a deep copy of the uvicorn logging config
samdoran May 11, 2026
0c22a37
Fixup docs
samdoran May 11, 2026
9ca0a89
Use caplop instead of creating a fake logging handler
samdoran May 12, 2026
bd93a16
Get correct logger and do not mess with global state
samdoran May 12, 2026
6a21677
Use constant for default logger name
samdoran May 12, 2026
774e1d1
Create a fixture used by all tests that ensure logging state is correct
samdoran May 12, 2026
55350d3
Fix doc string
samdoran May 12, 2026
0e8dff3
Add a test case for the default logging configuration
samdoran May 12, 2026
f602886
Update doc string
samdoran May 21, 2026
7f404f4
Implement recursive dict merging to avoid external dependency
samdoran May 21, 2026
1d0c776
Properly set log level if —verbose flag is passed
samdoran May 21, 2026
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
3 changes: 2 additions & 1 deletion src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,11 @@
# Environment variable name for configurable log level
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR: Final[str] = "LIGHTSPEED_STACK_LOG_LEVEL"
# Default log level when environment variable is not set
DEFAULT_LOGGER_NAME: Final[str] = "lightspeed_stack"
DEFAULT_LOG_LEVEL: Final[str] = "INFO"
# Default log format for plain-text logging in non-TTY environments
DEFAULT_LOG_FORMAT: Final[str] = (
"%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s"
"%(asctime)s.%(msecs)03d %(levelprefix)s %(message)s [%(name)s:%(lineno)d]"
)
# Environment variable to force StreamHandler instead of RichHandler
# Set to any non-empty value to disable RichHandler
Expand Down
33 changes: 4 additions & 29 deletions src/lightspeed_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,17 @@
main() function.
"""

import logging
import os
import sys
from argparse import ArgumentParser

from configuration import configuration
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
from log import create_log_handler, get_logger, resolve_log_level
from log import get_logger, setup_logging
from runners.quota_scheduler import start_quota_scheduler
from runners.uvicorn import start_uvicorn
from utils import schema_dumper

# Resolve log level and handler from centralized logging utilities
log_level = resolve_log_level()

# Configure root logger. basicConfig(force=True) is intentionally root-logger-specific.
# RichHandler needs format="%(message)s" to prevent double-formatting by the root Formatter.
handler = create_log_handler()
if sys.stderr.isatty():
logging.basicConfig(
level=log_level,
format="%(message)s",
datefmt="[%X]",
handlers=[handler],
force=True,
)
else:
logging.basicConfig(
level=log_level,
handlers=[handler],
force=True,
)

setup_logging()
Comment thread
samdoran marked this conversation as resolved.
logger = get_logger(__name__)


Expand Down Expand Up @@ -119,11 +97,8 @@ def main() -> None:

if args.verbose:
os.environ[LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR] = "DEBUG"
logging.getLogger().setLevel(logging.DEBUG)
for logger_name in logging.Logger.manager.loggerDict:
existing_logger = logging.getLogger(logger_name)
if isinstance(existing_logger, logging.Logger):
existing_logger.setLevel(logging.DEBUG)
setup_logging.cache_clear()
setup_logging()

configuration.load_configuration(args.config_file)
logger.info("Configuration: %s", configuration.configuration)
Expand Down
148 changes: 91 additions & 57 deletions src/log.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
"""Log utilities."""
Comment thread
samdoran marked this conversation as resolved.

import logging
import logging.config
import os
import sys
import typing as t
from copy import deepcopy
from datetime import datetime
from functools import lru_cache

from rich.logging import RichHandler
import uvicorn.config
from rich.text import Text

from constants import (
DEFAULT_LOG_FORMAT,
DEFAULT_LOG_LEVEL,
DEFAULT_LOGGER_NAME,
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR,
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR,
)


def _ms_time_format(dt: datetime) -> Text:
"""Format datetime object with zero padded milliseconds."""
return Text(dt.strftime("%Y-%m-%d %H:%M:%S.") + f"{dt.microsecond // 1000:03d}")


def _deep_merge(
mapping: dict[t.Any, t.Any], updates: dict[t.Any, t.Any]
) -> dict[t.Any, t.Any]:
"""Recursively merge updates into mapping."""
merged = mapping.copy()
for k, v in updates.items():
if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
merged[k] = _deep_merge(merged[k], v)
else:
merged[k] = v

return merged


def resolve_log_level() -> int:
"""
Resolve and validate the log level from environment variable.
Expand Down Expand Up @@ -50,62 +76,70 @@ def resolve_log_level() -> int:
return validated_level


def create_log_handler() -> logging.Handler:
"""
Create and return a configured log handler based on TTY availability and environment settings.

If LIGHTSPEED_STACK_DISABLE_RICH_HANDLER is set to any non-empty value,
returns a StreamHandler with plain-text formatting. Otherwise, if stderr
is connected to a terminal (TTY), returns a RichHandler for rich-formatted
console output. If neither condition is met, returns a StreamHandler with
plain-text formatting suitable for non-TTY environments (e.g., containers).

Returns:
logging.Handler: A configured handler instance (RichHandler or StreamHandler).
"""
# Check if RichHandler is explicitly disabled via environment variable
if os.environ.get(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR):
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
return handler

if sys.stderr.isatty():
# RichHandler's columnar layout assumes a real terminal.
# RichHandler handles its own formatting, so no formatter is set.
return RichHandler()

# In containers without a TTY, Rich falls back to 80 columns and
# the columns consume most of that width, leaving ~40 chars for the actual message.
# Tracebacks become nearly unreadable. Use a plain StreamHandler instead.
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
return handler


def get_logger(name: str) -> logging.Logger:
"""
Get a logger configured for Rich console output.

The returned logger has its level set based on the LIGHTSPEED_STACK_LOG_LEVEL
environment variable (defaults to INFO), its handlers replaced with a single
handler (RichHandler for TTY or StreamHandler for non-TTY), and propagation
to ancestor loggers disabled.

Parameters:
----------
name (str): Name of the logger to retrieve or create.

Returns:
-------
logging.Logger: The configured logger instance.
"""
logger = logging.getLogger(name)
"""Create a common logger for all modules in this package."""
# The need for this function should be removed in the future.
#
# Normally this is derived from the package name (__name__).
#
# Since this program is sometimes called from from the entrypoint and
# sometimes called from src/lightspeed_stack.py, the value for __name__
# does not contain a consistent root value.
#
# How the application is installed and run needs to be streamlined so that
# __name__ provides the expected value in all cases.
return logging.getLogger(f"{DEFAULT_LOGGER_NAME}.{name}")
Comment thread
samdoran marked this conversation as resolved.


@lru_cache
def setup_logging() -> dict[t.Any, t.Any]:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"""Create logging configuration."""
handler = "default"
log_level = resolve_log_level()
if sys.stderr.isatty() and not os.environ.get(
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR
):
handler = "rich"

logging_conf = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"rich": {
"()": "rich.logging.RichHandler",
"show_time": True,
"log_time_format": _ms_time_format,
"level": log_level,
},
},
"loggers": {
DEFAULT_LOGGER_NAME: {
"handlers": [handler],
"level": log_level,
"propagate": False,
},
"llama_stack_client": {
"handlers": [handler],
"level": log_level,
"propagate": False,
},
},
}

# Create a deep copy of uvicorn's logging config to avoid mutating global state.
merged_config = _deep_merge(deepcopy(uvicorn.config.LOGGING_CONFIG), logging_conf)

if handler == "rich":
merged_config["loggers"]["uvicorn"]["handlers"] = [handler]
merged_config["loggers"]["uvicorn.access"]["handlers"] = [handler]
else:
merged_config["formatters"]["access"]["fmt"] = (
"%(asctime)s.%(msecs)03d %(levelprefix)s "
'%(client_addr)s - "%(request_line)s" %(status_code)s'
)
merged_config["formatters"]["default"]["fmt"] = DEFAULT_LOG_FORMAT
merged_config["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"

# Skip reconfiguration if logger already has handlers from a prior call
if logger.handlers:
return logger
logging.config.dictConfig(merged_config)

logger.handlers = [create_log_handler()]
logger.propagate = False
logger.setLevel(resolve_log_level())
return logger
return merged_config
18 changes: 13 additions & 5 deletions src/runners/uvicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@

import uvicorn

from log import get_logger, resolve_log_level
from log import get_logger, resolve_log_level, setup_logging
from models.config import ServiceConfiguration

logger = get_logger(__name__)


def start_uvicorn(configuration: ServiceConfiguration) -> None:
def start_uvicorn(
configuration: ServiceConfiguration,
log_config: dict | None = None,
) -> None:
"""Start the Uvicorn server using the provided service configuration.

Parameters:
----------
configuration (ServiceConfiguration): Configuration providing host,
port, workers, and `tls_config` (including `tls_key_path`,
`tls_certificate_path`, and `tls_key_password`). TLS fields may be None
and will be forwarded to uvicorn.run as provided.
port, workers, and `tls_config` (including `tls_key_path`,
`tls_certificate_path`, and `tls_key_password`). TLS fields may be None
and will be forwarded to uvicorn.run as provided.
log_config (dict | None): Logging configuration dictionary passed to
uvicorn.run. When None, defaults to the output of setup_logging().
"""
log_level = resolve_log_level()
logger.info("Starting Uvicorn with log level %s", logging.getLevelName(log_level))
if log_config is None:
log_config = setup_logging()

# please note:
# TLS fields can be None, which means we will pass those values as None to uvicorn.run
Expand All @@ -30,6 +37,7 @@ def start_uvicorn(configuration: ServiceConfiguration) -> None:
host=configuration.host,
port=configuration.port,
workers=configuration.workers,
log_config=log_config,
log_level=log_level,
ssl_keyfile=configuration.tls_config.tls_key_path,
ssl_certfile=configuration.tls_config.tls_certificate_path,
Expand Down
Loading
Loading