|
1 | 1 | """Log utilities.""" |
2 | 2 |
|
3 | 3 | import logging |
| 4 | +import logging.config |
4 | 5 | import os |
5 | 6 | import sys |
| 7 | +import typing as t |
| 8 | +from copy import deepcopy |
| 9 | +from datetime import datetime |
6 | 10 |
|
7 | | -from rich.logging import RichHandler |
| 11 | +import uvicorn.config |
| 12 | +from rich.text import Text |
8 | 13 |
|
9 | 14 | from constants import ( |
10 | 15 | DEFAULT_LOG_FORMAT, |
11 | 16 | DEFAULT_LOG_LEVEL, |
| 17 | + DEFAULT_LOGGER_NAME, |
12 | 18 | LIGHTSPEED_STACK_DISABLE_RICH_HANDLER_ENV_VAR, |
13 | 19 | LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, |
14 | 20 | ) |
15 | 21 |
|
16 | 22 |
|
| 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 | + |
17 | 42 | def resolve_log_level() -> int: |
18 | 43 | """ |
19 | 44 | Resolve and validate the log level from environment variable. |
@@ -50,62 +75,73 @@ def resolve_log_level() -> int: |
50 | 75 | return validated_level |
51 | 76 |
|
52 | 77 |
|
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 | | - |
85 | 78 | 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" |
97 | 141 |
|
98 | | - Returns: |
99 | | - ------- |
100 | | - logging.Logger: The configured logger instance. |
101 | | - """ |
102 | | - logger = logging.getLogger(name) |
| 142 | + return merged_config |
103 | 143 |
|
104 | | - # Skip reconfiguration if logger already has handlers from a prior call |
105 | | - if logger.handlers: |
106 | | - return logger |
107 | 144 |
|
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()) |
0 commit comments