Skip to content

Commit c1d6122

Browse files
committed
feat: add --readable-logs CLI option for human-readable log output
Signed-off-by: fege <fmosca@redhat.com>
1 parent 9d0af74 commit c1d6122

4 files changed

Lines changed: 98 additions & 22 deletions

File tree

conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def pytest_addoption(parser: Parser) -> None:
5454
serving_arguments_group = parser.getgroup(name="Serving arguments")
5555
model_validation_automation_group = parser.getgroup(name="Model Validation Automation")
5656
hf_group = parser.getgroup(name="Hugging Face")
57+
logging_group = parser.getgroup(name="Logging")
5758
model_registry_group = parser.getgroup(name="Model Registry options")
5859
# AWS config and credentials options
5960
aws_group.addoption(
@@ -206,6 +207,14 @@ def pytest_addoption(parser: Parser) -> None:
206207
help="Indicates if the model registry tests are to be run against custom namespace",
207208
)
208209

210+
# Logging options
211+
logging_group.addoption(
212+
"--readable-logs",
213+
action="store_true",
214+
default=False,
215+
help="Write logs in human-readable format instead of JSON",
216+
)
217+
209218

210219
def pytest_cmdline_main(config: Any) -> None:
211220
config.option.basetemp = py_config["tmp_base_dir"] = f"{config.option.basetemp}-{shortuuid.uuid()}"
@@ -326,6 +335,7 @@ def pytest_sessionstart(session: Session) -> None:
326335
log_level=log_level,
327336
thread_name=thread_name,
328337
enable_console=enable_console_value,
338+
human_readable=session.config.getoption("--readable-logs"),
329339
)
330340

331341
# Now safe to log after configuration is applied (only to file when console disabled)

docs/GETTING_STARTED.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,25 @@ podman run -v $HOME:/mnt/host:Z -e KUBECONFIG=/mnt/host/kubeconfig quay.io/ope
171171

172172
## Debugging test failures
173173

174-
### Converting log file to human-readable format
174+
### Human-readable log output
175175

176-
Test logs are written in JSON format to `pytest-tests.log`. To convert them to a readable text format:
176+
By default, logs are written in JSON format to `pytest-tests.log`. To write logs in human-readable format instead, use the `--readable-logs` flag:
177177

178178
```bash
179-
sed 's/^[^{]*//' pytest-tests.log | jq -r '"\(.timestamp) [\(.level)] \(.logger): \(.event)"'
179+
uv run pytest --readable-logs tests/my_test.py
180180
```
181181

182-
Example output:
182+
This produces output like:
183183

184184
```text
185-
2026-03-24T16:46:07.485621+00:00 [info] conftest: Writing tests log to pytest-tests.log
186-
2026-03-24T16:46:07.502027+00:00 [info] ocp_resources.resource: Trying to get client via new_client_from_config
185+
2026-03-25T10:48:56.864955+00:00 conftest INFO Writing tests log to pytest-tests.log (conftest.py:342)
186+
2026-03-25T10:48:56.881095+00:00 ocp_resources.resource INFO Trying to get client via new_client_from_config (resource.py:272)
187+
```
188+
189+
### Converting existing JSON logs
190+
191+
To convert an existing JSON log file to readable format:
192+
193+
```bash
194+
sed 's/^[^{]*//' pytest-tests.log | jq -r '"\(.timestamp) [\(.level)] \(.logger): \(.event)"'
187195
```

utilities/logger.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
55
from typing import Any
66

7-
from utilities.opendatahub_logger import DuplicateFilter, WrapperLogFormatter
7+
from utilities.opendatahub_logger import DuplicateFilter, WrapperLogFormatter, set_human_readable
88

99
LOGGER = logging.getLogger(__name__)
1010

@@ -22,7 +22,11 @@ def __repr__(self) -> str:
2222

2323

2424
def setup_logging(
25-
log_level: int, log_file: str = "/tmp/pytest-tests.log", thread_name: str | None = None, enable_console: bool = True
25+
log_level: int,
26+
log_file: str = "/tmp/pytest-tests.log",
27+
thread_name: str | None = None,
28+
enable_console: bool = True,
29+
human_readable: bool = False,
2630
) -> QueueListener:
2731
"""
2832
Setup basic/root logging using QueueHandler/QueueListener
@@ -118,6 +122,8 @@ def setup_logging(
118122
if handler != root_log_queue_handler:
119123
logging.root.removeHandler(hdlr=handler)
120124

125+
set_human_readable(enabled=human_readable)
126+
121127
log_listener.start()
122128
return log_listener
123129

utilities/opendatahub_logger.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,37 @@ def format(self, record: logging.LogRecord) -> str:
125125

126126

127127
_initialized = False
128+
_human_readable = False
129+
_original_add_handler = logging.Logger.addHandler
130+
131+
132+
def set_human_readable(enabled: bool) -> None:
133+
"""Set log output format and re-initialize structlog if already configured."""
134+
global _human_readable, _initialized
135+
_human_readable = enabled
136+
_initialized = False
137+
_initialize()
138+
139+
140+
_LEVEL_COLORS = {
141+
"DEBUG": _FG_CODES["cyan"],
142+
"INFO": _FG_CODES["green"],
143+
"WARNING": _FG_CODES["yellow"],
144+
"ERROR": _FG_CODES["red"],
145+
"CRITICAL": _FG_CODES["red"] + _BG_CODES["white"],
146+
}
147+
148+
149+
class ThirdPartyHumanReadableFormatter(logging.Formatter):
150+
"""Formatter for third-party logs in human-readable mode with colors and source location."""
151+
152+
def format(self, record: logging.LogRecord) -> str:
153+
timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
154+
color = _LEVEL_COLORS.get(record.levelname, "")
155+
reset = _RESET if color else ""
156+
filename = record.pathname.rsplit("/", 1)[-1] if record.pathname else ""
157+
msg = record.getMessage()
158+
return f"{timestamp} {record.name} {color}{record.levelname}{reset} {msg} ({filename}:{record.lineno})"
128159

129160

130161
def _initialize() -> None:
@@ -133,36 +164,51 @@ def _initialize() -> None:
133164
if _initialized:
134165
return
135166

136-
structlog.configure(
137-
processors=[
167+
if _human_readable:
168+
processors = [
169+
structlog.processors.KeyValueRenderer(key_order=["event"]),
170+
]
171+
else:
172+
processors = [
138173
structlog.stdlib.add_logger_name,
139174
structlog.stdlib.add_log_level,
140175
structlog.processors.TimeStamper(fmt="ISO", utc=True),
141176
structlog.processors.JSONRenderer(),
142-
],
177+
]
178+
179+
structlog.configure(
180+
processors=processors,
143181
wrapper_class=structlog.stdlib.BoundLogger,
144182
logger_factory=structlog.stdlib.LoggerFactory(),
145-
cache_logger_on_first_use=True,
183+
cache_logger_on_first_use=False,
146184
)
147185

148-
# Patch addHandler so new loggers get JSON formatting
149-
original_add_handler = logging.Logger.addHandler
186+
third_party_formatter = ThirdPartyHumanReadableFormatter() if _human_readable else ThirdPartyJSONFormatter()
187+
188+
# Formatters that should not be replaced:
189+
# - JSONOnlyFormatter: used by StructlogWrapper (structlog already formats the message)
190+
# - WrapperLogFormatter: used by setup_logging's QueueHandlers (only protected in HR mode)
191+
_skip_formatters: tuple[type, ...] = (JSONOnlyFormatter,)
192+
if _human_readable:
193+
_skip_formatters = (*_skip_formatters, WrapperLogFormatter)
150194

195+
# Patch addHandler so new loggers (e.g. from ocp_resources/simple_logger)
196+
# get the correct formatter on their handlers.
151197
def patched_add_handler(self: logging.Logger, hdlr: logging.Handler) -> None:
152-
if not isinstance(hdlr.formatter, (ThirdPartyJSONFormatter, JSONOnlyFormatter)):
153-
hdlr.setFormatter(fmt=ThirdPartyJSONFormatter())
154-
original_add_handler(self, hdlr) # noqa: FCN001
198+
if not isinstance(hdlr.formatter, _skip_formatters):
199+
hdlr.setFormatter(fmt=third_party_formatter)
200+
_original_add_handler(self, hdlr) # noqa: FCN001
155201

156202
logging.Logger.addHandler = patched_add_handler # type: ignore[method-assign]
157203

158-
# Apply JSON formatter to all existing handlers on all loggers
204+
# Apply formatter to all existing handlers on all loggers
159205
all_loggers = [logging.getLogger()] + [
160206
logger for logger in logging.root.manager.loggerDict.values() if isinstance(logger, logging.Logger)
161207
]
162208
for logger in all_loggers:
163209
for handler in logger.handlers:
164-
if isinstance(handler.formatter, (logging.Formatter, type(None))):
165-
handler.setFormatter(fmt=ThirdPartyJSONFormatter())
210+
if not isinstance(handler.formatter, _skip_formatters):
211+
handler.setFormatter(fmt=third_party_formatter)
166212

167213
_initialized = True
168214

@@ -185,8 +231,14 @@ def _log(self, level: str, msg: Any, *args: Any, **kwargs: Any) -> None:
185231
if args:
186232
msg_str = msg_str % args
187233

188-
log_method = getattr(self._logger, level.lower())
189-
log_method(event=msg_str, **kwargs)
234+
if _human_readable:
235+
std_logger = logging.getLogger(self.name)
236+
log_method = getattr(std_logger, level.lower())
237+
extra_str = " ".join(f"{k}={v}" for k, v in kwargs.items()) if kwargs else ""
238+
log_method(f"{msg_str} {extra_str}" if extra_str else msg_str, stacklevel=3) # noqa: FCN001
239+
else:
240+
log_method = getattr(self._logger, level.lower())
241+
log_method(event=msg_str, **kwargs)
190242

191243
def info(self, msg: Any, *args: Any, **kwargs: Any) -> None:
192244
self._log("info", msg, *args, **kwargs) # noqa: FCN001

0 commit comments

Comments
 (0)