Skip to content

Commit 9fec2c5

Browse files
dbasunagclaudefegejgarciao
authored
fix: use json format directly if available and add information on debugging (#1289)
* fix: use json format directly if available and add information on debugging Signed-off-by: Debarati Basu-Nag <dbasunag@redhat.com> Co-Authored-By: Claude <noreply@anthropic.com> * feat: add --readable-logs CLI option for human-readable log output Signed-off-by: fege <fmosca@redhat.com> --------- Signed-off-by: Debarati Basu-Nag <dbasunag@redhat.com> Signed-off-by: fege <fmosca@redhat.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: fege <fmosca@redhat.com> Co-authored-by: Jorge <jgarciao@users.noreply.github.com>
1 parent 68bb285 commit 9fec2c5

File tree

4 files changed

+126
-25
lines changed

4 files changed

+126
-25
lines changed

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,28 @@ To run tests in containerized environment:
168168
```bash
169169
podman run -v $HOME:/mnt/host:Z -e KUBECONFIG=/mnt/host/kubeconfig quay.io/opendatahub/opendatahub-tests
170170
```
171+
172+
## Debugging test failures
173+
174+
### Human-readable log output
175+
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:
177+
178+
```bash
179+
uv run pytest --readable-logs tests/my_test.py
180+
```
181+
182+
This produces output like:
183+
184+
```text
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)"'
195+
```

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: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,59 @@ def format(self, record: logging.LogRecord) -> str:
103103

104104

105105
class ThirdPartyJSONFormatter(logging.Formatter):
106-
"""Custom formatter that converts third-party logging to JSON format"""
106+
"""Custom formatter that converts third-party logging to JSON format.
107+
108+
If the message is already valid JSON (e.g. from structlog), it is passed through as-is.
109+
"""
107110

108111
def format(self, record: logging.LogRecord) -> str:
109-
return json.dumps({
110-
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
111-
"logger": record.name,
112-
"level": record.levelname.lower(),
113-
"event": record.getMessage(),
114-
"filename": record.pathname.split("/")[-1] if record.pathname else "",
115-
"lineno": str(record.lineno),
116-
})
112+
msg = record.getMessage()
113+
try:
114+
json.loads(msg)
115+
return msg
116+
except json.JSONDecodeError, TypeError:
117+
return json.dumps({
118+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
119+
"logger": record.name,
120+
"level": record.levelname.lower(),
121+
"event": msg,
122+
"filename": record.pathname.split("/")[-1] if record.pathname else "",
123+
"lineno": str(record.lineno),
124+
})
117125

118126

119127
_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})"
120159

121160

122161
def _initialize() -> None:
@@ -125,36 +164,51 @@ def _initialize() -> None:
125164
if _initialized:
126165
return
127166

128-
structlog.configure(
129-
processors=[
167+
if _human_readable:
168+
processors = [
169+
structlog.processors.KeyValueRenderer(key_order=["event"]),
170+
]
171+
else:
172+
processors = [
130173
structlog.stdlib.add_logger_name,
131174
structlog.stdlib.add_log_level,
132175
structlog.processors.TimeStamper(fmt="ISO", utc=True),
133176
structlog.processors.JSONRenderer(),
134-
],
177+
]
178+
179+
structlog.configure(
180+
processors=processors,
135181
wrapper_class=structlog.stdlib.BoundLogger,
136182
logger_factory=structlog.stdlib.LoggerFactory(),
137-
cache_logger_on_first_use=True,
183+
cache_logger_on_first_use=False,
138184
)
139185

140-
# Patch addHandler so new loggers get JSON formatting
141-
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)
142194

195+
# Patch addHandler so new loggers (e.g. from ocp_resources/simple_logger)
196+
# get the correct formatter on their handlers.
143197
def patched_add_handler(self: logging.Logger, hdlr: logging.Handler) -> None:
144-
if not isinstance(hdlr.formatter, (ThirdPartyJSONFormatter, JSONOnlyFormatter)):
145-
hdlr.setFormatter(fmt=ThirdPartyJSONFormatter())
146-
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
147201

148202
logging.Logger.addHandler = patched_add_handler # type: ignore[method-assign]
149203

150-
# Apply JSON formatter to all existing handlers on all loggers
204+
# Apply formatter to all existing handlers on all loggers
151205
all_loggers = [logging.getLogger()] + [
152206
logger for logger in logging.root.manager.loggerDict.values() if isinstance(logger, logging.Logger)
153207
]
154208
for logger in all_loggers:
155209
for handler in logger.handlers:
156-
if isinstance(handler.formatter, (logging.Formatter, type(None))):
157-
handler.setFormatter(fmt=ThirdPartyJSONFormatter())
210+
if not isinstance(handler.formatter, _skip_formatters):
211+
handler.setFormatter(fmt=third_party_formatter)
158212

159213
_initialized = True
160214

@@ -177,8 +231,14 @@ def _log(self, level: str, msg: Any, *args: Any, **kwargs: Any) -> None:
177231
if args:
178232
msg_str = msg_str % args
179233

180-
log_method = getattr(self._logger, level.lower())
181-
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)
182242

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

0 commit comments

Comments
 (0)