fix(sqllab): surface stacktrace in SQL Lab error responses (#28248)#40585
Conversation
Regression for #28248: SQL Lab query execution errors should surface stacktrace information to help users debug failures. Adds 6 failing tests that pin the two specific bugs: 1. handle_query_error (legacy sql_lab.py) and _handle_query_error (celery_task.py) call logger.debug without exc_info, silently discarding the Python traceback in application logs. 2. Neither error-handling function includes a 'stacktrace' key in the returned payload dict, so callers have no way to surface the root cause in the UI or log aggregation pipelines. The tests are written to FAIL until the production code is fixed. Closes #28248 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Review Agent Run #0da1b9Actionable Suggestions - 0Filtered by Review RulesBito filtered these suggestions based on rules created automatically for your feedback. Manage rules.
Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #40585 +/- ##
==========================================
- Coverage 63.97% 63.94% -0.03%
==========================================
Files 2654 2658 +4
Lines 142768 143019 +251
Branches 32837 32870 +33
==========================================
+ Hits 91329 91454 +125
- Misses 49878 50001 +123
- Partials 1561 1564 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Query execution errors in both the legacy sql_lab.py path and the newer celery_task.py path were logging without exc_info and returning error payloads with no stacktrace key, making failures hard to debug. Fixes #28248 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ry_error Covers the branch where traceback.format_exc() returns an empty string, so the stacktrace key is correctly omitted from the error payload. Resolves the coverage failure (99% → 100%) for superset/sql/execution/celery_task.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Surfaces Python tracebacks for SQL Lab query failures in both the legacy (superset/sql_lab.py) and newer (superset/sql/execution/celery_task.py) execution paths, so operators and users get root-cause information instead of a bare error string.
Changes:
- Switch
logger.debug(...)→logger.exception(...)in the outerexceptblocks ofget_sql_resultsandexecute_sql_task, and addlogger.exception(...)insidehandle_query_error/_handle_query_error. - Add a
stacktracekey (populated fromtraceback.format_exc()) to the dict returned by both error handlers. - Add unit/regression tests asserting both the logging and payload behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| superset/sql_lab.py | Imports traceback, logs exception with traceback, and adds stacktrace to the error payload. |
| superset/sql/execution/celery_task.py | Mirrors the legacy fix in the newer Celery execution path. |
| tests/unit_tests/sql/execution/test_celery_task.py | Adds a test for the empty-traceback branch in _handle_query_error. |
| tests/unit_tests/test_sqllab_error_stacktrace.py | New regression suite covering logging + payload behavior for both execution paths. |
…ging
- Remove logger.exception calls from handle_query_error (sql_lab.py)
and _handle_query_error (celery_task.py); callers already log with
logger.exception so the inner calls produced duplicate tracebacks
- Gate stacktrace payload field on app.config.get("SHOW_STACKTRACE")
in both helpers so raw Python tracebacks are not exposed to
unprivileged SQL Lab users (default: SHOW_STACKTRACE=False)
- Update tests: replace single unconditional stacktrace assertions with
separate enabled/disabled tests keyed on SHOW_STACKTRACE; remove
assertions about logger calls inside the helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add two tests to tests/unit_tests/sql/execution/test_celery_task.py that cover the previously uncovered branch in superset/sql/execution/celery_task._handle_query_error (lines 107-108): one where SHOW_STACKTRACE=True and format_exc returns a real stacktrace (line 108 covered), and one where SHOW_STACKTRACE=True but format_exc returns empty (the 107->109 branch covered). Together these bring superset/sql/ coverage to 100% as required by CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Review Agent Run #458df8Actionable Suggestions - 0Additional Suggestions - 1
Review Details
Bito Usage GuideCommands Type the following command in the pull request comment and save the comment.
Refer to the documentation for additional commands. Configuration This repository uses Documentation & Help |
aminghadersohi
left a comment
There was a problem hiding this comment.
PR #40585 — Code Review
HEAD SHA: 25e0b5dc32b6944f45a21c6e98c78465bcfe20c4
Diff: 4 production lines changed, 350+ lines of new tests across 4 files.
Mode: code
Second opinion: Not needed — small focused fix, no security/migrations/multi-tenant/submodule changes.
Scan Coverage (all 19 run)
| # | Scan | Result |
|---|---|---|
| 1 | Inline imports without circular-import comment | Pre-existing violations (_serialize_payload, _serialize_result_set) — not introduced by this PR |
| 2 | SQL injection | 0 violations |
| 3 | RLS bypass | 0 violations |
| 4 | Hardcoded credentials | 0 violations |
| 5 | Debug logging in exception handlers | Fixed (debug→exception) ✓ |
| 6 | Broad except Exception |
Pre-existing, intentional Celery task pattern |
| 7 | Missing type hints | 0 violations in changed lines |
| 8 | Mutable default arguments | 0 violations |
| 9 | Exception chaining | 0 violations |
| 10 | Assert in production | 0 violations |
| 11 | TODO/FIXME | Pre-existing TODO in sql_lab.py (not introduced by PR) |
| 12 | Dead code | 0 violations |
| 13 | Walrus operator | Correct usage in if/assignment context |
| 14 | Config access inconsistency | NIT — see below |
| 15 | Test false positives | MEDIUM — see below |
| 16 | Silent fallbacks | 0 violations |
| 17 | DRY | Acceptable — the stacktrace block is duplicated across the two files, but they're parallel implementations |
| 18 | Structural source-inspection tests | MEDIUM — see below |
| 19 | Error message exposure | Properly gated behind SHOW_STACKTRACE |
MEDIUM
M1 — traceback.format_exc() is always truthy; the inner guard never filters anything
traceback.format_exc() — in Python 3.11 and earlier — returns 'NoneType: None\n' (not an empty string) when no exception is currently active. That string is truthy, so the if stacktrace := traceback.format_exc(): guard never actually suppresses a spurious stacktrace if _handle_query_error were called outside an except block.
In practice this is fine for the current call sites (both are inside except handlers), but the guard is misleading: it reads as "only include the stacktrace if there is one," while in reality it always fires when SHOW_STACKTRACE is True. Several tests validate the supposedly-false branch by mocking format_exc to return "" — a state that can't happen naturally in Python 3.
# celery_task.py:106-108 / sql_lab.py:126-128
if app.config.get("SHOW_STACKTRACE"):
if stacktrace := traceback.format_exc(): # always True in practice
payload["stacktrace"] = stacktraceConsider using ex.__traceback__ directly, which makes the intent clear and is immune to sys.exc_info() state at call time:
if app.config.get("SHOW_STACKTRACE") and ex.__traceback__ is not None:
payload["stacktrace"] = "".join(
traceback.format_exception(type(ex), ex, ex.__traceback__)
)M2 — Structural source-inspection tests are fragile and don't test runtime behavior
test_legacy_get_sql_results_outer_except_logs_exc_info and test_new_execute_sql_task_outer_except_logs_exc_info (in test_sqllab_error_stacktrace.py, lines 145 and 258) use inspect.getsource() to check that the string "logger.exception(" appears anywhere in the function body. This pattern has several failure modes:
- Passes if the string is inside a comment
- Passes if
logger.exceptionis in a different, unrelated except block within the function - Fails for semantically equivalent alternatives (e.g.,
log.exception(...)orlogger.error(..., exc_info=True))
A behavioral test is more reliable:
def test_get_sql_results_outer_except_logs_with_exc_info(mocker):
mock_logger = mocker.patch("superset.sql_lab.logger")
mocker.patch("superset.sql_lab.execute_sql_statements", side_effect=RuntimeError("boom"))
mocker.patch("superset.sql_lab.get_query", return_value=mock_query)
get_sql_results(123, "SELECT 1", ...)
mock_logger.exception.assert_called_once()
# or: assert any(call[1].get("exc_info") for call in mock_logger.error.call_args_list)M3 — test_handle_query_error_no_stacktrace_when_format_exc_empty is a false positive
test_celery_task.py:181 does not set SHOW_STACKTRACE=True. Without that flag the outer if app.config.get("SHOW_STACKTRACE"): check is already falsy, so traceback.format_exc is never called and the "stacktrace" not in payload assertion passes trivially — regardless of whether the inner if stacktrace := ... guard exists at all. The format_exc mock is dead.
The intent appears to be to test "SHOW_STACKTRACE=True, but format_exc returns empty → no stacktrace key". That scenario is already covered by test_handle_query_error_omits_stacktrace_when_show_stacktrace_enabled_but_empty (line 221). This test should either be removed, or updated to add mocker.patch.dict(current_app.config, {"SHOW_STACKTRACE": True}).
NIT
N1 — traceback.format_exc() is called after db.session.commit()
Both _handle_query_error (celery_task.py:103) and handle_query_error (sql_lab.py) call db.session.commit() before calling traceback.format_exc(). If commit() raises a secondary exception (unlikely but possible under heavy load or connection failure), sys.exc_info() at that point reflects the commit failure, not the original query error. Capturing the stacktrace from ex.__traceback__ (as shown in M1) avoids this entirely.
N2 — app.config["TROUBLESHOOTING_LINK"] vs app.config.get("SHOW_STACKTRACE") inconsistency in sql_lab.py
sql_lab.py:129 uses bracket access for TROUBLESHOOTING_LINK (will KeyError if the key is absent from config):
if troubleshooting_link := app.config["TROUBLESHOOTING_LINK"]:The new code two lines above uses safe .get():
if app.config.get("SHOW_STACKTRACE"):The corresponding code in celery_task.py uses .get() for both config keys consistently. TROUBLESHOOTING_LINK has a default in config.py so this won't blow up in production, but the inconsistency is mildly jarring. Worth aligning while handle_query_error is being touched.
N3 — No frontend change to render stacktrace in the SQL Lab UI
The stacktrace key is added to the error payload that's stored in the results backend and returned to the client, but there are no frontend changes to surface it. Inspecting the sqlLab.ts types and action handlers, there's no stacktrace field in the QueryObject interface. Unless existing generic error display code already forwards arbitrary payload keys, the stack trace is only visible to operators reading the results backend directly or via raw API responses.
If exposing this to UI users was part of the intent, a follow-up issue or a note in the PR description would help track it. If it's purely operator-facing (i.e., logs only), the payload["stacktrace"] in the stored results is extra data that goes unread.
PRAISE
The fix is minimal and surgical. Both the logging upgrade (debug → exception with implicit exc_info) and the payload addition are applied consistently across both execution paths. The SHOW_STACKTRACE gate is the right design choice — raw Python tracebacks exposing module paths and library versions are off by default.
The celery_task._handle_query_error correctly handles the TIMED_OUT status preservation (unlike its legacy counterpart in sql_lab.py that still has the commented-out TODO about it) — good that the new code doesn't repeat that technical debt.
The test_sqllab_error_stacktrace.py file has genuinely useful regression tests for the payload inclusion/exclusion branches, and the try: raise RuntimeError("boom") / except ... as ex: pattern correctly ensures sys.exc_info() is populated when calling the handlers.
SUMMARY
Fixes #28248. SQL Lab query execution errors were silently discarding the Python traceback in two ways:
logger.debug("Query %d: %s", query_id, ex)withoutexc_info, so the full traceback never appeared in Celery worker logs.handle_query_error(legacysql_lab.py) and_handle_query_error(newercelery_task.py) returned payloads withstatus,error, anderrorskeys only — nostacktracekey, leaving users and operators with no way to find the root cause.Fix:
logger.debug(...)→logger.exception(...)in the outerexcept Exceptionblocks in bothget_sql_resultsandexecute_sql_tasklogger.exception(...)in bothhandle_query_errorand_handle_query_errorstacktracekey (populated withtraceback.format_exc()) to the returned payload dicts in both functionsimport tracebackto both filesBEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
N/A — backend-only fix, no UI changes.
TESTING INSTRUCTIONS
ADDITIONAL INFORMATION
Closes #28248