Skip to content

Commit e7cf56f

Browse files
authored
Merge pull request #1703 from samdoran/use-existing-logger
LCORE- Unify logging configuration and setup
1 parent 7a3705d commit e7cf56f

11 files changed

Lines changed: 259 additions & 244 deletions

File tree

src/client.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
enrich_solr,
2121
synthesize_to_file,
2222
)
23-
from log import get_logger
23+
from log import get_logger, setup_logging
2424
from models.api.responses.error import ServiceUnavailableResponse
2525
from models.config import LlamaStackConfiguration
2626
from utils.types import Singleton
@@ -75,6 +75,11 @@ async def _load_library_client(self, config: LlamaStackConfiguration) -> None:
7575
await client.initialize()
7676
self._lsc = client
7777

78+
# Re-apply logging configuration after ogx's setup_logging() is called.
79+
# This ensures the desired logging configuration is applied when
80+
# using AsyncLlamaStackAsLibraryClient.
81+
setup_logging()
82+
7883
def _synthesize_library_config(self) -> str:
7984
"""Synthesize a unified-mode run.yaml and return its on-disk path.
8085
@@ -191,6 +196,11 @@ async def reload_library_client(self) -> AsyncLlamaStackClient:
191196
)
192197
raise HTTPException(**error_response.model_dump()) from e
193198
self._lsc = client
199+
# Re-apply logging configuration after ogx's setup_logging() is called.
200+
# This ensures the desired logging configuration is applied when
201+
# using AsyncLlamaStackAsLibraryClient.
202+
setup_logging()
203+
194204
return client
195205

196206
async def check_model_available(self, model_id: str) -> tuple[bool, str]:
@@ -287,6 +297,11 @@ async def update_azure_token(self) -> AsyncLlamaStackClient:
287297
)
288298
await client.initialize()
289299
self._lsc = client
300+
# Re-apply logging configuration after ogx's setup_logging() is called.
301+
# This ensures the desired logging configuration is applied when
302+
# using AsyncLlamaStackAsLibraryClient.
303+
setup_logging()
304+
290305
return client
291306

292307
# Service client mode

src/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,11 @@
248248
# Environment variable name for configurable log level
249249
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR: Final[str] = "LIGHTSPEED_STACK_LOG_LEVEL"
250250
# Default log level when environment variable is not set
251+
DEFAULT_LOGGER_NAME: Final[str] = "lightspeed_stack"
251252
DEFAULT_LOG_LEVEL: Final[str] = "INFO"
252253
# Default log format for plain-text logging in non-TTY environments
253254
DEFAULT_LOG_FORMAT: Final[str] = (
254-
"%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s"
255+
"%(asctime)s.%(msecs)03d %(levelprefix)s %(message)s [%(name)s:%(lineno)d]"
255256
)
256257
# Environment variable to force StreamHandler instead of RichHandler
257258
# Set to any non-empty value to disable RichHandler

src/lightspeed_stack.py

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,18 @@
44
main() function.
55
"""
66

7-
import logging
87
import os
9-
import sys
108
from argparse import ArgumentParser
119

1210
import constants
1311
from configuration import configuration
1412
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
15-
from log import create_log_handler, get_logger, resolve_log_level
13+
from log import get_logger, setup_logging
1614
from runners.quota_scheduler import start_quota_scheduler
1715
from runners.uvicorn import start_uvicorn
1816
from utils import schema_dumper
1917

20-
# Resolve log level and handler from centralized logging utilities
21-
log_level = resolve_log_level()
22-
23-
# Configure root logger. basicConfig(force=True) is intentionally root-logger-specific.
24-
# RichHandler needs format="%(message)s" to prevent double-formatting by the root Formatter.
25-
handler = create_log_handler()
26-
if sys.stderr.isatty():
27-
logging.basicConfig(
28-
level=log_level,
29-
format="%(message)s",
30-
datefmt="[%X]",
31-
handlers=[handler],
32-
force=True,
33-
)
34-
else:
35-
logging.basicConfig(
36-
level=log_level,
37-
handlers=[handler],
38-
force=True,
39-
)
40-
18+
setup_logging()
4119
logger = get_logger(__name__)
4220

4321

@@ -128,11 +106,7 @@ def main() -> None:
128106

129107
if args.verbose:
130108
os.environ[LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR] = "DEBUG"
131-
logging.getLogger().setLevel(logging.DEBUG)
132-
for logger_name in logging.Logger.manager.loggerDict:
133-
existing_logger = logging.getLogger(logger_name)
134-
if isinstance(existing_logger, logging.Logger):
135-
existing_logger.setLevel(logging.DEBUG)
109+
setup_logging()
136110

137111
configuration.load_configuration(args.config_file)
138112
logger.info("Configuration: %s", configuration.configuration)

src/log.py

Lines changed: 92 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
11
"""Log utilities."""
22

33
import logging
4+
import logging.config
45
import os
56
import sys
7+
import typing as t
8+
from copy import deepcopy
9+
from datetime import datetime
610

7-
from rich.logging import RichHandler
11+
import uvicorn.config
12+
from rich.text import Text
813

914
from constants import (
1015
DEFAULT_LOG_FORMAT,
1116
DEFAULT_LOG_LEVEL,
17+
DEFAULT_LOGGER_NAME,
1218
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR,
1319
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR,
1420
)
1521

1622

23+
def _ms_time_format(dt: datetime) -> Text:
24+
"""Format datetime object with zero padded milliseconds."""
25+
return Text(dt.strftime("%Y-%m-%d %H:%M:%S.") + f"{dt.microsecond // 1000:03d}")
26+
27+
28+
def _deep_merge(
29+
mapping: dict[t.Any, t.Any], updates: dict[t.Any, t.Any]
30+
) -> dict[t.Any, t.Any]:
31+
"""Recursively merge updates into mapping."""
32+
merged = mapping.copy()
33+
for k, v in updates.items():
34+
if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
35+
merged[k] = _deep_merge(merged[k], v)
36+
else:
37+
merged[k] = v
38+
39+
return merged
40+
41+
1742
def resolve_log_level() -> int:
1843
"""
1944
Resolve and validate the log level from environment variable.
@@ -50,62 +75,73 @@ def resolve_log_level() -> int:
5075
return validated_level
5176

5277

53-
def create_log_handler() -> logging.Handler:
54-
"""
55-
Create and return a configured log handler based on TTY availability and environment settings.
56-
57-
If LIGHTSPEED_STACK_DISABLE_RICH_HANDLER is set to any non-empty value,
58-
returns a StreamHandler with plain-text formatting. Otherwise, if stderr
59-
is connected to a terminal (TTY), returns a RichHandler for rich-formatted
60-
console output. If neither condition is met, returns a StreamHandler with
61-
plain-text formatting suitable for non-TTY environments (e.g., containers).
62-
63-
Returns:
64-
logging.Handler: A configured handler instance (RichHandler or StreamHandler).
65-
"""
66-
# Check if RichHandler is explicitly disabled via environment variable
67-
if os.environ.get(LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR):
68-
handler = logging.StreamHandler()
69-
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
70-
return handler
71-
72-
if sys.stderr.isatty():
73-
# RichHandler's columnar layout assumes a real terminal.
74-
# RichHandler handles its own formatting, so no formatter is set.
75-
return RichHandler()
76-
77-
# In containers without a TTY, Rich falls back to 80 columns and
78-
# the columns consume most of that width, leaving ~40 chars for the actual message.
79-
# Tracebacks become nearly unreadable. Use a plain StreamHandler instead.
80-
handler = logging.StreamHandler()
81-
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
82-
return handler
83-
84-
8578
def get_logger(name: str) -> logging.Logger:
86-
"""
87-
Get a logger configured for Rich console output.
88-
89-
The returned logger has its level set based on the LIGHTSPEED_STACK_LOG_LEVEL
90-
environment variable (defaults to INFO), its handlers replaced with a single
91-
handler (RichHandler for TTY or StreamHandler for non-TTY), and propagation
92-
to ancestor loggers disabled.
93-
94-
Parameters:
95-
----------
96-
name (str): Name of the logger to retrieve or create.
79+
"""Create a common logger for all modules in this package."""
80+
# The need for this function should be removed in the future.
81+
#
82+
# Normally this is derived from the package name (__name__).
83+
#
84+
# Since this program is sometimes called from from the entrypoint and
85+
# sometimes called from src/lightspeed_stack.py, the value for __name__
86+
# does not contain a consistent root value.
87+
#
88+
# How the application is installed and run needs to be streamlined so that
89+
# __name__ provides the expected value in all cases.
90+
return logging.getLogger(f"{DEFAULT_LOGGER_NAME}.{name}")
91+
92+
93+
def build_logging_config() -> dict[t.Any, t.Any]:
94+
"""Create logging configuration."""
95+
handler = "default"
96+
log_level = resolve_log_level()
97+
if sys.stderr.isatty() and not os.environ.get(
98+
LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR
99+
):
100+
handler = "rich"
101+
102+
logging_conf = {
103+
"version": 1,
104+
"disable_existing_loggers": False,
105+
"handlers": {
106+
"rich": {
107+
"()": "rich.logging.RichHandler",
108+
"show_time": True,
109+
"log_time_format": _ms_time_format,
110+
"level": log_level,
111+
},
112+
},
113+
"loggers": {
114+
DEFAULT_LOGGER_NAME: {
115+
"handlers": [handler],
116+
"level": log_level,
117+
"propagate": False,
118+
},
119+
"llama_stack_client": {
120+
"handlers": [handler],
121+
"level": log_level,
122+
"propagate": False,
123+
},
124+
},
125+
}
126+
127+
# Create a deep copy of uvicorn's logging config to avoid mutating global state.
128+
merged_config = _deep_merge(deepcopy(uvicorn.config.LOGGING_CONFIG), logging_conf)
129+
130+
if handler == "rich":
131+
merged_config["loggers"]["uvicorn"]["handlers"] = [handler]
132+
merged_config["loggers"]["uvicorn.access"]["handlers"] = [handler]
133+
else:
134+
merged_config["formatters"]["access"]["fmt"] = (
135+
"%(asctime)s.%(msecs)03d %(levelprefix)s "
136+
'%(client_addr)s - "%(request_line)s" %(status_code)s'
137+
)
138+
merged_config["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
139+
merged_config["formatters"]["default"]["fmt"] = DEFAULT_LOG_FORMAT
140+
merged_config["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
97141

98-
Returns:
99-
-------
100-
logging.Logger: The configured logger instance.
101-
"""
102-
logger = logging.getLogger(name)
142+
return merged_config
103143

104-
# Skip reconfiguration if logger already has handlers from a prior call
105-
if logger.handlers:
106-
return logger
107144

108-
logger.handlers = [create_log_handler()]
109-
logger.propagate = False
110-
logger.setLevel(resolve_log_level())
111-
return logger
145+
def setup_logging() -> None:
146+
"""Set up main logging configuration."""
147+
logging.config.dictConfig(build_logging_config())

src/runners/uvicorn.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,31 @@
44

55
import uvicorn
66

7-
from log import get_logger, resolve_log_level
7+
from log import build_logging_config, get_logger, resolve_log_level
88
from models.config import ServiceConfiguration
99

1010
logger = get_logger(__name__)
1111

1212

13-
def start_uvicorn(configuration: ServiceConfiguration) -> None:
13+
def start_uvicorn(
14+
configuration: ServiceConfiguration,
15+
log_config: dict | None = None,
16+
) -> None:
1417
"""Start the Uvicorn server using the provided service configuration.
1518
1619
Parameters:
1720
----------
1821
configuration (ServiceConfiguration): Configuration providing host,
19-
port, workers, and `tls_config` (including `tls_key_path`,
20-
`tls_certificate_path`, and `tls_key_password`). TLS fields may be None
21-
and will be forwarded to uvicorn.run as provided.
22+
port, workers, and `tls_config` (including `tls_key_path`,
23+
`tls_certificate_path`, and `tls_key_password`). TLS fields may be None
24+
and will be forwarded to uvicorn.run as provided.
25+
log_config (dict | None): Logging configuration dictionary passed to
26+
uvicorn.run. When None, defaults to the output of setup_logging().
2227
"""
2328
log_level = resolve_log_level()
2429
logger.info("Starting Uvicorn with log level %s", logging.getLevelName(log_level))
30+
if log_config is None:
31+
log_config = build_logging_config()
2532

2633
# please note:
2734
# TLS fields can be None, which means we will pass those values as None to uvicorn.run
@@ -30,10 +37,10 @@ def start_uvicorn(configuration: ServiceConfiguration) -> None:
3037
host=configuration.host,
3138
port=configuration.port,
3239
workers=configuration.workers,
40+
log_config=log_config,
3341
log_level=log_level,
3442
ssl_keyfile=configuration.tls_config.tls_key_path,
3543
ssl_certfile=configuration.tls_config.tls_certificate_path,
3644
ssl_keyfile_password=str(configuration.tls_config.tls_key_password or ""),
37-
use_colors=True,
3845
access_log=True,
3946
)

0 commit comments

Comments
 (0)