Skip to content

Commit 051c06c

Browse files
authored
Merge pull request #1212 from major/refactor/deduplicate-logging
RSPEED-2477: Deduplicate logging setup across entry point, log utility, and uvicorn runner
2 parents 5341133 + bf2ccce commit 051c06c

6 files changed

Lines changed: 151 additions & 85 deletions

File tree

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,5 @@
183183
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR = "LIGHTSPEED_STACK_LOG_LEVEL"
184184
# Default log level when environment variable is not set
185185
DEFAULT_LOG_LEVEL = "INFO"
186+
# Default log format for plain-text logging in non-TTY environments
187+
DEFAULT_LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s"

src/lightspeed_stack.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,31 @@
99
import sys
1010
from argparse import ArgumentParser
1111

12-
13-
from rich.logging import RichHandler
14-
15-
from log import get_logger
12+
from log import get_logger, resolve_log_level, create_log_handler
1613
from configuration import configuration
1714
from runners.uvicorn import start_uvicorn
1815
from runners.quota_scheduler import start_quota_scheduler
1916
from utils import schema_dumper
20-
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL
21-
22-
# Read log level from environment variable with validation
23-
log_level_str = os.environ.get(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL)
24-
log_level = getattr(logging, log_level_str.upper(), None)
25-
if not isinstance(log_level, int):
26-
print(
27-
f"WARNING: Invalid log level '{log_level_str}', falling back to {DEFAULT_LOG_LEVEL}",
28-
file=sys.stderr,
29-
)
30-
log_level = getattr(logging, DEFAULT_LOG_LEVEL)
17+
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
18+
19+
# Resolve log level and handler from centralized logging utilities
20+
log_level = resolve_log_level()
3121

32-
# RichHandler's columnar layout produces very narrow log output in containers
33-
# without a TTY (Rich falls back to 80 columns, columns consume ~40 of those).
34-
# Use a plain format when there's no terminal attached.
22+
# Configure root logger. basicConfig(force=True) is intentionally root-logger-specific.
23+
# RichHandler needs format="%(message)s" to prevent double-formatting by the root Formatter.
24+
handler = create_log_handler()
3525
if sys.stderr.isatty():
3626
logging.basicConfig(
3727
level=log_level,
3828
format="%(message)s",
3929
datefmt="[%X]",
40-
handlers=[RichHandler()],
30+
handlers=[handler],
4131
force=True,
4232
)
4333
else:
4434
logging.basicConfig(
4535
level=log_level,
46-
format="%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s",
36+
handlers=[handler],
4737
force=True,
4838
)
4939

src/log.py

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,72 @@
66

77
from rich.logging import RichHandler
88

9-
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL
9+
from constants import (
10+
LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR,
11+
DEFAULT_LOG_LEVEL,
12+
DEFAULT_LOG_FORMAT,
13+
)
14+
15+
16+
def resolve_log_level() -> int:
17+
"""
18+
Resolve and validate the log level from environment variable.
19+
20+
Reads the LIGHTSPEED_STACK_LOG_LEVEL environment variable and validates
21+
it against Python's logging module. If the environment variable is not set,
22+
defaults to DEFAULT_LOG_LEVEL. If the value is invalid, logs a warning and
23+
falls back to DEFAULT_LOG_LEVEL.
24+
25+
Parameters:
26+
None
27+
28+
Returns:
29+
int: A valid logging level constant (e.g., logging.INFO, logging.DEBUG).
30+
"""
31+
level_str = os.environ.get(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL)
32+
33+
# Validate the level string and convert to logging level constant
34+
validated_level = getattr(logging, level_str.upper(), None)
35+
if not isinstance(validated_level, int):
36+
# Write directly to stderr instead of using a logger. This function is
37+
# called at module-import time (before logging is configured), so routing
38+
# through a logger produces inconsistent output depending on root-logger
39+
# state.
40+
print(
41+
f"WARNING: Invalid log level '{level_str}', "
42+
f"falling back to {DEFAULT_LOG_LEVEL}",
43+
file=sys.stderr,
44+
)
45+
validated_level = getattr(logging, DEFAULT_LOG_LEVEL)
46+
47+
return validated_level
48+
49+
50+
def create_log_handler() -> logging.Handler:
51+
"""
52+
Create and return a configured log handler based on TTY availability.
53+
54+
If stderr is connected to a terminal (TTY), returns a RichHandler for
55+
rich-formatted console output. Otherwise, returns a StreamHandler with
56+
plain-text formatting suitable for non-TTY environments (e.g., containers).
57+
58+
Parameters:
59+
None
60+
61+
Returns:
62+
logging.Handler: A configured handler instance (RichHandler or StreamHandler).
63+
"""
64+
if sys.stderr.isatty():
65+
# RichHandler's columnar layout assumes a real terminal.
66+
# RichHandler handles its own formatting, so no formatter is set.
67+
return RichHandler()
68+
69+
# In containers without a TTY, Rich falls back to 80 columns and
70+
# the columns consume most of that width, leaving ~40 chars for the actual message.
71+
# Tracebacks become nearly unreadable. Use a plain StreamHandler instead.
72+
handler = logging.StreamHandler()
73+
handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
74+
return handler
1075

1176

1277
def get_logger(name: str) -> logging.Logger:
@@ -15,8 +80,8 @@ def get_logger(name: str) -> logging.Logger:
1580
1681
The returned logger has its level set based on the LIGHTSPEED_STACK_LOG_LEVEL
1782
environment variable (defaults to INFO), its handlers replaced with a single
18-
RichHandler for rich-formatted console output, and propagation to ancestor
19-
loggers disabled.
83+
handler (RichHandler for TTY or StreamHandler for non-TTY), and propagation
84+
to ancestor loggers disabled.
2085
2186
Parameters:
2287
name (str): Name of the logger to retrieve or create.
@@ -30,34 +95,7 @@ def get_logger(name: str) -> logging.Logger:
3095
if logger.handlers:
3196
return logger
3297

33-
# RichHandler's columnar layout (timestamp, level, right-aligned filename) assumes
34-
# a real terminal. In containers without a TTY, Rich falls back to 80 columns and
35-
# the columns consume most of that width, leaving ~40 chars for the actual message.
36-
# Tracebacks become nearly unreadable. Use a plain StreamHandler when there's no TTY.
37-
if sys.stderr.isatty():
38-
logger.handlers = [RichHandler()]
39-
else:
40-
handler = logging.StreamHandler()
41-
handler.setFormatter(
42-
logging.Formatter(
43-
"%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s"
44-
)
45-
)
46-
logger.handlers = [handler]
98+
logger.handlers = [create_log_handler()]
4799
logger.propagate = False
48-
49-
# Read log level from environment variable with default fallback
50-
level_str = os.environ.get(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL)
51-
52-
# Validate the level string and convert to logging level constant
53-
validated_level = getattr(logging, level_str.upper(), None)
54-
if not isinstance(validated_level, int):
55-
logger.warning(
56-
"Invalid log level '%s', falling back to %s",
57-
level_str,
58-
DEFAULT_LOG_LEVEL,
59-
)
60-
validated_level = getattr(logging, DEFAULT_LOG_LEVEL)
61-
62-
logger.setLevel(validated_level)
100+
logger.setLevel(resolve_log_level())
63101
return logger

src/runners/uvicorn.py

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
11
"""Uvicorn runner."""
22

33
import logging
4-
import os
54

65
import uvicorn
76

8-
from constants import DEFAULT_LOG_LEVEL, LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
9-
from log import get_logger
7+
from log import get_logger, resolve_log_level
108
from models.config import ServiceConfiguration
119

1210
logger = get_logger(__name__)
1311

1412

15-
def _resolve_log_level() -> int:
16-
"""Resolve the uvicorn log level from the environment.
17-
18-
Reads the LIGHTSPEED_STACK_LOG_LEVEL environment variable and converts it
19-
to a Python logging level constant. Falls back to the default log level
20-
when the variable is unset or contains an invalid value.
21-
22-
Returns:
23-
The resolved logging level as an integer constant.
24-
"""
25-
level_str = os.environ.get(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_LEVEL)
26-
level = getattr(logging, level_str.upper(), None)
27-
if not isinstance(level, int):
28-
logger.warning(
29-
"Invalid log level '%s', falling back to %s",
30-
level_str,
31-
DEFAULT_LOG_LEVEL,
32-
)
33-
level = getattr(logging, DEFAULT_LOG_LEVEL)
34-
return level
35-
36-
3713
def start_uvicorn(configuration: ServiceConfiguration) -> None:
3814
"""Start the Uvicorn server using the provided service configuration.
3915
@@ -43,7 +19,7 @@ def start_uvicorn(configuration: ServiceConfiguration) -> None:
4319
`tls_certificate_path`, and `tls_key_password`). TLS fields may be None
4420
and will be forwarded to uvicorn.run as provided.
4521
"""
46-
log_level = _resolve_log_level()
22+
log_level = resolve_log_level()
4723
logger.info("Starting Uvicorn with log level %s", logging.getLevelName(log_level))
4824

4925
# please note:

tests/unit/runners/test_uvicorn_runner.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from pytest_mock import MockerFixture
88

99
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
10+
from log import resolve_log_level
1011
from models.config import ServiceConfiguration, TLSConfiguration
11-
from runners.uvicorn import _resolve_log_level, start_uvicorn
12+
from runners.uvicorn import start_uvicorn
1213

1314

1415
def test_start_uvicorn(mocker: MockerFixture) -> None:
@@ -146,17 +147,17 @@ def test_start_uvicorn_with_root_path(mocker: MockerFixture) -> None:
146147
def test_resolve_log_level_from_env(
147148
monkeypatch: pytest.MonkeyPatch, env_value: str, expected_level: int
148149
) -> None:
149-
"""Test that _resolve_log_level resolves env var values to logging constants."""
150+
"""Test that resolve_log_level resolves env var values to logging constants."""
150151
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, env_value)
151-
assert _resolve_log_level() == expected_level
152+
assert resolve_log_level() == expected_level
152153

153154

154155
def test_resolve_log_level_defaults_to_info(
155156
monkeypatch: pytest.MonkeyPatch,
156157
) -> None:
157-
"""Test that _resolve_log_level falls back to INFO when the env var is unset."""
158+
"""Test that resolve_log_level falls back to INFO when the env var is unset."""
158159
monkeypatch.delenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, raising=False)
159-
assert _resolve_log_level() == logging.INFO
160+
assert resolve_log_level() == logging.INFO
160161

161162

162163
def test_start_uvicorn_respects_debug_log_level(

tests/unit/test_log.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Unit tests for functions defined in src/log.py."""
22

33
import logging
4+
45
import pytest
6+
from pytest_mock import MockerFixture
7+
from rich.logging import RichHandler
58

6-
from log import get_logger
7-
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR
9+
from log import get_logger, resolve_log_level, create_log_handler
10+
from constants import LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, DEFAULT_LOG_FORMAT
811

912

1013
def test_get_logger() -> None:
@@ -57,3 +60,59 @@ def test_get_logger_default_log_level(monkeypatch: pytest.MonkeyPatch) -> None:
5760

5861
logger = get_logger("test_default")
5962
assert logger.level == logging.INFO
63+
64+
65+
@pytest.mark.parametrize(
66+
("level_name", "expected_level"),
67+
[
68+
("DEBUG", logging.DEBUG),
69+
("debug", logging.DEBUG),
70+
("INFO", logging.INFO),
71+
("WARNING", logging.WARNING),
72+
("ERROR", logging.ERROR),
73+
("CRITICAL", logging.CRITICAL),
74+
("critical", logging.CRITICAL),
75+
],
76+
)
77+
def test_resolve_log_level(
78+
monkeypatch: pytest.MonkeyPatch, level_name: str, expected_level: int
79+
) -> None:
80+
"""Test that resolve_log_level correctly resolves valid level names."""
81+
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, level_name)
82+
assert resolve_log_level() == expected_level
83+
84+
85+
def test_resolve_log_level_invalid_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
86+
"""Test that resolve_log_level falls back to INFO for invalid values."""
87+
monkeypatch.setenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, "BOGUS")
88+
assert resolve_log_level() == logging.INFO
89+
90+
91+
def test_resolve_log_level_default(monkeypatch: pytest.MonkeyPatch) -> None:
92+
"""Test that resolve_log_level defaults to INFO when env var is unset."""
93+
monkeypatch.delenv(LIGHTSPEED_STACK_LOG_LEVEL_ENV_VAR, raising=False)
94+
assert resolve_log_level() == logging.INFO
95+
96+
97+
def test_create_log_handler_tty(mocker: MockerFixture) -> None:
98+
"""Test that create_log_handler returns RichHandler when TTY is available."""
99+
mocker.patch("sys.stderr.isatty", return_value=True)
100+
handler = create_log_handler()
101+
assert isinstance(handler, RichHandler)
102+
103+
104+
def test_create_log_handler_non_tty(mocker: MockerFixture) -> None:
105+
"""Test that create_log_handler returns StreamHandler when no TTY."""
106+
mocker.patch("sys.stderr.isatty", return_value=False)
107+
handler = create_log_handler()
108+
assert isinstance(handler, logging.StreamHandler)
109+
assert not isinstance(handler, RichHandler)
110+
111+
112+
def test_create_log_handler_non_tty_format(mocker: MockerFixture) -> None:
113+
"""Test that non-TTY handler uses DEFAULT_LOG_FORMAT."""
114+
mocker.patch("sys.stderr.isatty", return_value=False)
115+
handler = create_log_handler()
116+
assert handler.formatter is not None
117+
# pylint: disable=protected-access
118+
assert handler.formatter._fmt == DEFAULT_LOG_FORMAT

0 commit comments

Comments
 (0)