Skip to content

Commit 6e66e55

Browse files
committed
feat: display CDA server stack traces in debug mode
1 parent ff3e06a commit 6e66e55

4 files changed

Lines changed: 220 additions & 6 deletions

File tree

cwmscli/__main__.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from cwmscli.load import __main__ as load
1111
from cwmscli.usgs import usgs_group
1212
from cwmscli.utils.click_help import add_version_to_help_tree
13-
from cwmscli.utils.friendly_errors import to_user_facing_error
13+
from cwmscli.utils.friendly_errors import (
14+
cda_stack_trace,
15+
format_cda_stack_trace,
16+
to_user_facing_error,
17+
)
1418
from cwmscli.utils.logging import (
1519
LoggingConfig,
1620
apply_logging_policies,
@@ -111,9 +115,19 @@ def main() -> None:
111115
except SystemExit:
112116
raise
113117
except click.ClickException as e:
118+
debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
119+
if debug:
120+
server_stack_trace = cda_stack_trace(e)
121+
if server_stack_trace is not None:
122+
click.echo(format_cda_stack_trace(server_stack_trace), err=True)
123+
raise SystemExit(e.exit_code)
114124
e.show()
115125
raise SystemExit(e.exit_code)
116126
except Exception as e:
127+
# The environment switch supports failures before Click configures logging.
128+
# Once CLI setup has run, --log-level DEBUG enables the same behavior.
129+
debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
130+
117131
if is_cert_verify_error(e) and not debug:
118132
# Keep this short, no stack trace.
119133
logging.error(
@@ -122,14 +136,20 @@ def main() -> None:
122136
click.echo(ssl_help_text(), err=True)
123137
raise SystemExit(2)
124138

125-
if not debug:
139+
if debug:
140+
server_stack_trace = cda_stack_trace(e)
141+
if server_stack_trace is not None:
142+
click.echo(format_cda_stack_trace(server_stack_trace), err=True)
143+
raise SystemExit(1)
144+
else:
126145
friendly_error = to_user_facing_error(e)
127146
if friendly_error is not None:
128147
logging.debug("Suppressed traceback for CLI exception", exc_info=e)
129148
friendly_error.show()
130149
raise SystemExit(friendly_error.exit_code)
131150

132-
# If debug is enabled (or it's not a cert verify error), keep the normal failure behavior.
151+
# Preserve raw exception behavior when debug is enabled but CDA did not
152+
# provide a server stack trace.
133153
raise
134154

135155

cwmscli/utils/friendly_errors.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
from __future__ import annotations
22

33
import json
4+
from dataclasses import dataclass
45
from typing import Iterable, Optional, Set
56

67
import click
78

9+
from cwmscli.utils import colors
10+
11+
12+
@dataclass(frozen=True)
13+
class CdaStackTrace:
14+
message: Optional[str]
15+
incident_identifier: Optional[str]
16+
lines: tuple[str, ...]
17+
818

919
class UserFacingError(click.ClickException):
1020
def __init__(
@@ -58,6 +68,84 @@ def _response_json_field(response, field: str) -> Optional[str]:
5868
return str(value)
5969

6070

71+
def _response_json(response) -> Optional[dict]:
72+
text = _response_text(response)
73+
if not text:
74+
return None
75+
try:
76+
payload = json.loads(text)
77+
except Exception:
78+
return None
79+
return payload if isinstance(payload, dict) else None
80+
81+
82+
def cda_stack_trace(exc: BaseException) -> Optional[CdaStackTrace]:
83+
"""Extract a CDA-provided server stack trace from an exception chain."""
84+
85+
for candidate in _walk_exception_chain(exc):
86+
response = getattr(candidate, "response", None)
87+
if response is None:
88+
continue
89+
90+
payload = _response_json(response)
91+
if payload is None:
92+
continue
93+
94+
details = payload.get("details")
95+
if not isinstance(details, dict):
96+
continue
97+
98+
stack_trace_lines = details.get("stackTraceLines")
99+
if not isinstance(stack_trace_lines, list):
100+
continue
101+
102+
lines = tuple(str(line) for line in stack_trace_lines if line is not None)
103+
if not lines:
104+
continue
105+
106+
message = payload.get("message")
107+
incident_identifier = payload.get("incidentIdentifier")
108+
return CdaStackTrace(
109+
message=str(message) if message not in (None, "") else None,
110+
incident_identifier=(
111+
str(incident_identifier)
112+
if incident_identifier not in (None, "")
113+
else None
114+
),
115+
lines=lines,
116+
)
117+
118+
return None
119+
120+
121+
def format_cda_stack_trace(stack_trace: CdaStackTrace) -> str:
122+
"""Format a CDA-provided server stack trace for terminal output."""
123+
124+
heading = colors.err("CDA server stack trace")
125+
if stack_trace.incident_identifier:
126+
heading += (
127+
" "
128+
+ colors.c("(incidentIdentifier: ", "yellow", bright=True)
129+
+ colors.c(stack_trace.incident_identifier, "cyan", bright=True)
130+
+ colors.c(")", "yellow", bright=True)
131+
)
132+
133+
output = [heading]
134+
if stack_trace.message:
135+
output.append(colors.warn(stack_trace.message))
136+
137+
for index, line in enumerate(stack_trace.lines):
138+
stripped = line.lstrip()
139+
if index == 0 or stripped.startswith("Caused by:"):
140+
output.append(colors.err(line))
141+
elif stripped.startswith("at ") or stripped.startswith("..."):
142+
output.append(colors.c(line, "cyan"))
143+
else:
144+
output.append(colors.dim(line))
145+
146+
return "\n".join(output)
147+
148+
61149
def _trim_message(message: str) -> str:
62150
message = message.strip()
63151
if not message:

docs/cli/api_arguments.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,16 @@ Example:
9191
If you were looking for a ``--debug-level`` flag, use ``--log-level DEBUG``
9292
instead.
9393

94+
When CDA returns ``details.stackTraceLines`` in an authenticated error response,
95+
debug mode displays that server stack trace with terminal-friendly formatting.
96+
The trace is only available when CDA is configured to return it and the
97+
authenticated user has CDA's ``SHOW STACK TRACE`` role. Normal log levels keep
98+
the concise user-facing error and incident identifier.
99+
94100
For certain exception paths, ``cwms-cli`` also checks ``CWMS_CLI_DEBUG``. When
95101
that environment variable is set to ``1``, ``true``, ``yes``, or ``on``, the
96-
CLI keeps the normal exception behavior instead of suppressing some friendly
97-
error handling paths.
102+
CLI enables the same debug exception behavior. If CDA does not provide a server
103+
stack trace, cwms-cli keeps the raw local exception behavior for diagnosis.
98104

99105
See also
100106
--------

tests/cli/test_main_error_handling.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import logging
23
import sys
34

45
import pytest
@@ -14,14 +15,17 @@ def __init__(
1415
*,
1516
reason="",
1617
url="https://example.test/cwms-data/resource",
17-
incident=None
18+
incident=None,
19+
stack_trace_lines=None,
1820
):
1921
self.status_code = status_code
2022
self.reason = reason
2123
self.url = url
2224
payload = {"message": message}
2325
if incident is not None:
2426
payload["incidentIdentifier"] = incident
27+
if stack_trace_lines is not None:
28+
payload["details"] = {"stackTraceLines": stack_trace_lines}
2529
self.text = json.dumps(payload)
2630
self.content = self.text.encode("utf-8")
2731

@@ -139,3 +143,99 @@ def fake_cli(*args, **kwargs):
139143

140144
with pytest.raises(RuntimeError, match="boom"):
141145
cli_main.main()
146+
147+
148+
def test_main_formats_cda_stack_trace_when_debug_env_enabled(monkeypatch, capsys):
149+
from cwms.api import ApiError
150+
151+
def fake_cli(*args, **kwargs):
152+
raise ApiError(
153+
_FakeResponse(
154+
400,
155+
"Text 'not-a-date' could not be parsed at index 0",
156+
reason="Bad Request",
157+
incident="trace-123",
158+
stack_trace_lines=[
159+
"java.time.format.DateTimeParseException: invalid date",
160+
"\tat cwms.cda.helpers.DateUtils.parseUserDate(DateUtils.java:91)",
161+
],
162+
)
163+
)
164+
165+
monkeypatch.setattr(cli_main, "cli", fake_cli)
166+
monkeypatch.setattr(sys, "argv", ["cwms-cli", "dummy"])
167+
monkeypatch.setenv("CWMS_CLI_DEBUG", "1")
168+
169+
with pytest.raises(SystemExit) as exc:
170+
cli_main.main()
171+
172+
captured = capsys.readouterr()
173+
assert exc.value.code == 1
174+
assert "CDA server stack trace" in captured.err
175+
assert "incidentIdentifier: trace-123" in captured.err
176+
assert "java.time.format.DateTimeParseException" in captured.err
177+
assert "DateUtils.parseUserDate" in captured.err
178+
assert "Traceback (most recent call last)" not in captured.err
179+
180+
181+
def test_main_log_level_debug_formats_cda_stack_trace(monkeypatch, capsys):
182+
from cwms.api import ApiError
183+
184+
previous_level = logging.getLogger().level
185+
186+
def fake_cli(*args, **kwargs):
187+
logging.getLogger().setLevel(logging.DEBUG)
188+
raise ApiError(
189+
_FakeResponse(
190+
500,
191+
"System Error",
192+
incident="trace-456",
193+
stack_trace_lines=["java.lang.RuntimeException: boom"],
194+
)
195+
)
196+
197+
monkeypatch.setattr(cli_main, "cli", fake_cli)
198+
monkeypatch.setattr(sys, "argv", ["cwms-cli", "--log-level", "DEBUG", "dummy"])
199+
monkeypatch.delenv("CWMS_CLI_DEBUG", raising=False)
200+
201+
try:
202+
with pytest.raises(SystemExit) as exc:
203+
cli_main.main()
204+
finally:
205+
logging.getLogger().setLevel(previous_level)
206+
207+
captured = capsys.readouterr()
208+
assert exc.value.code == 1
209+
assert "CDA server stack trace" in captured.err
210+
assert "java.lang.RuntimeException: boom" in captured.err
211+
212+
213+
def test_main_debug_finds_cda_stack_behind_click_exception(monkeypatch, capsys):
214+
from cwms.api import ApiError
215+
216+
def fake_cli(*args, **kwargs):
217+
try:
218+
raise ApiError(
219+
_FakeResponse(
220+
500,
221+
"System Error",
222+
incident="trace-789",
223+
stack_trace_lines=["java.lang.NullPointerException: missing"],
224+
)
225+
)
226+
except ApiError:
227+
raise click.ClickException("Friendly command error") from None
228+
229+
monkeypatch.setattr(cli_main, "cli", fake_cli)
230+
monkeypatch.setattr(sys, "argv", ["cwms-cli", "dummy"])
231+
monkeypatch.setenv("CWMS_CLI_DEBUG", "1")
232+
233+
with pytest.raises(SystemExit) as exc:
234+
cli_main.main()
235+
236+
captured = capsys.readouterr()
237+
assert exc.value.code == 1
238+
assert "CDA server stack trace" in captured.err
239+
assert "incidentIdentifier: trace-789" in captured.err
240+
assert "java.lang.NullPointerException: missing" in captured.err
241+
assert "Friendly command error" not in captured.err

0 commit comments

Comments
 (0)