Skip to content

fix(reports): fail loudly instead of falling back to unguarded screenshot when tiled capture fails - #42273

Merged
eschutho merged 1 commit into
masterfrom
fix-tiled-fallback-unguarded-screenshot
Jul 31, 2026
Merged

fix(reports): fail loudly instead of falling back to unguarded screenshot when tiled capture fails#42273
eschutho merged 1 commit into
masterfrom
fix-tiled-fallback-unguarded-screenshot

Conversation

@eschutho

@eschutho eschutho commented Jul 21, 2026

Copy link
Copy Markdown
Member

SUMMARY

WebDriverPlaywright.get_screenshot() fell back to _get_screenshot() — a raw page.screenshot()/element.screenshot() call with zero wait/readiness logic — whenever the tiled capture (take_tiled_screenshot()) returned a falsy result (None or b""). Unlike the tiled path itself or the non-tiled/standard branch in this same method, this fallback never waited for spinners to clear or charts to render, so it could silently deliver a screenshot of loading spinners or a blank dashboard in a report/thumbnail.

This PR removes that fallback. When tiled capture fails for any reason, the method now logs a WARNING (a capture/rendering issue, not a system fault — matching the level already used for the other capture-failure paths in this method) and raises PlaywrightTimeout, which propagates to a clean report failure the same way the other timeout paths in this method already do.

BEFORE/AFTER

Before:

if not img:
    logger.warning("Tiled screenshot failed, falling back to standard screenshot")
    img = WebDriverPlaywright._get_screenshot(page, element, element_name)

After:

if not img:
    logger.warning(
        "Tiled screenshot failed for url %s and no safe fallback "
        "exists; failing the report",
        url,
    )
    raise PlaywrightTimeout(f"Tiled screenshot failed for url {url}")

TESTING INSTRUCTIONS

  • tests/unit_tests/utils/webdriver_test.py::TestWebDriverPlaywrightFallback::test_tiled_screenshot_failure_raises_without_fallbacktake_tiled_screenshot() returns None → asserts PlaywrightTimeout is raised, page.screenshot/element.screenshot are never called, and the WARNING log fires with the expected message.
  • tests/unit_tests/utils/webdriver_test.py::TestWebDriverPlaywrightAnimationWaitOrder::test_tiled_empty_bytes_raises_without_fallback — same assertions for the b"" (falsy-but-not-None) case.
  • Ran pytest tests/unit_tests/utils/webdriver_test.py tests/unit_tests/utils/test_screenshot_utils.py — 59 passed, 1 pre-existing failure unrelated to this change (test_get_screenshot_handles_playwright_timeout fails on master too, due to a playwright package version mismatch with the test's no-arg PlaywrightTimeout() construction).
  • Ran pre-commit run --files superset/utils/webdriver.py tests/unit_tests/utils/webdriver_test.py — all hooks pass (ruff, ruff-format, mypy, pylint).

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

Not changed (out of scope for this PR):

  • take_tiled_screenshot() itself in screenshot_utils.py, and the tiling-routing decision (chart-count/height threshold) in webdriver.py.
  • The non-tiled/standard screenshot branch in WebDriverPlaywright.get_screenshot().
  • The Selenium webdriver path (WebDriverSelenium).
  • Pixel-level/image-content blank-screenshot validation — not built as a backstop here.

@dosubot dosubot Bot added the alert-reports Namespace | Anything related to the Alert & Reports feature label Jul 21, 2026
@bito-code-review

bito-code-review Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #483014

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: a7946db..a7946db
    • superset/utils/webdriver.py
    • tests/unit_tests/utils/webdriver_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit ff6b3f3
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a6ce301fac6060008f8fead
😎 Deploy Preview https://deploy-preview-42273--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 65.44%. Comparing base (263d793) to head (c66515e).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
superset/utils/webdriver.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #42273   +/-   ##
=======================================
  Coverage   65.44%   65.44%           
=======================================
  Files        2810     2810           
  Lines      159362   159364    +2     
  Branches    36372    36372           
=======================================
+ Hits       104292   104294    +2     
  Misses      53027    53027           
  Partials     2043     2043           
Flag Coverage Δ
hive 38.08% <0.00%> (-0.01%) ⬇️
mysql 57.82% <0.00%> (-0.01%) ⬇️
postgres 57.87% <0.00%> (+<0.01%) ⬆️
presto 39.98% <0.00%> (-0.01%) ⬇️
python 59.25% <0.00%> (+<0.01%) ⬆️
sqlite 57.49% <0.00%> (-0.01%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bito-code-review

bito-code-review Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #0a1c12

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: a7946db..08573c7
    • tests/unit_tests/utils/webdriver_test.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rusackas

Copy link
Copy Markdown
Member

Thanks, this certainly beats silently shipping a blank or spinner screenshot. Does a report on a retry-enabled schedule get another attempt after this raises, or does one flaky tiled capture now fail the whole run?

@eschutho

Copy link
Copy Markdown
Member Author

Rebased onto current master (single clean commit, replacing the previous merge-commit history; the separate ruff-format style commit is folded in).

Per review guidance, verified the fail-loud change still composes cleanly with the newly merged readiness flow (#42253/#42427):

  • The unguarded _get_screenshot fallback block this PR removes was untouched by the readiness merges — the cherry-pick applied without conflicts.
  • The raised PlaywrightTimeout still propagates: the enclosing handler in get_screenshot re-raises PlaywrightTimeout before the PlaywrightError catch-all, so the report fails loudly as intended.
  • tests/unit_tests/utils/ — 713 passed locally; ruff check + ruff format --check clean.

Comment on lines 565 to 578
if not img:
# _get_screenshot() has no wait/readiness logic at
# all, so falling back to it here would risk
# silently delivering a screenshot of spinners or
# a blank dashboard. Fail the report loudly
# instead of guessing at a "safer" fallback.
logger.warning(
(
"Tiled screenshot failed, "
"falling back to standard screenshot"
)
"Tiled screenshot failed for url %s and no "
"safe fallback exists; failing the report",
url,
)
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
raise PlaywrightTimeout(
f"Tiled screenshot failed for url {url}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The tiled helper returns None for non-timeout failures such as element lookup, JavaScript evaluation, screenshot capture, or tile-combination errors, but this branch converts every such failure into PlaywrightTimeout. That misclassifies system failures as readiness timeouts and discards the exception from the caller's control flow. Preserve the original exception (or introduce a dedicated tiled-capture failure exception and handle it explicitly) instead of synthesizing a timeout for all falsy results. [api mismatch]

Severity Level: Major ⚠️
- ❌ System capture errors receive timeout status 408.
- ⚠️ Original exception details are not propagated.
- ⚠️ Report failure classification becomes operationally misleading.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/utils/webdriver.py
**Line:** 565:578
**Comment:**
	*Api Mismatch: The tiled helper returns `None` for non-timeout failures such as element lookup, JavaScript evaluation, screenshot capture, or tile-combination errors, but this branch converts every such failure into `PlaywrightTimeout`. That misclassifies system failures as readiness timeouts and discards the exception from the caller's control flow. Preserve the original exception (or introduce a dedicated tiled-capture failure exception and handle it explicitly) instead of synthesizing a timeout for all falsy results.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is valid. The current implementation in superset/utils/webdriver.py forces a PlaywrightTimeout exception when take_tiled_screenshot returns None (or empty bytes), which conflates various failure modes (e.g., element lookup, JS errors) with a simple timeout. To resolve this, you should define a specific exception for tiled-capture failures and raise that instead of PlaywrightTimeout to preserve the original error context.

Here is a concise implementation to replace the generic timeout:

# Define a specific exception for tiled capture failures
class TiledCaptureError(Exception):
    pass

# In superset/utils/webdriver.py, replace the raise statement:
if not img:
    logger.warning("Tiled screenshot failed for url %s", url)
    raise TiledCaptureError(f"Tiled screenshot failed for url {url}")

I have checked the PR comments and there are no other pending review comments to address. Would you like me to assist with any other part of this PR?

superset/utils/webdriver.py

if not img:
    logger.warning("Tiled screenshot failed for url %s", url)
    raise TiledCaptureError(f"Tiled screenshot failed for url {url}")

…shot when tiled capture fails

The tiled screenshot path fell back to WebDriverPlaywright._get_screenshot(),
a raw capture with no wait/readiness logic, whenever take_tiled_screenshot()
returned a falsy result. That risked silently delivering a screenshot of
spinners or a blank dashboard. Replace the fallback with a WARNING log and a
raised PlaywrightTimeout, matching the existing failure pattern used
elsewhere in this method, so the report fails cleanly instead.

Co-Authored-By: Claude <noreply@anthropic.com>
@eschutho
eschutho force-pushed the fix-tiled-fallback-unguarded-screenshot branch from ff6b3f3 to c66515e Compare July 31, 2026 18:10
@eschutho

Copy link
Copy Markdown
Member Author

Ran an adversarial self-review on the rebase before requesting human review. Full result: no blockers — verified the raise sits only in the tiled branch (the other two _get_screenshot call sites are now readiness-gated by #42253/#42427, so they're no longer "unguarded" and are correctly left alone); PlaywrightTimeout propagates via the except PlaywrightTimeout: raise handler in both real and playwright-less alias modes; all production callers handle the new exception safely (reports → ReportScheduleScreenshotFailedError + notify, thumbnails → cache payload ERROR — no blank cached, Excel export → chart skipped-and-noted); len(img) after the raise is safe since img is guaranteed truthy; no stale tests elsewhere assert the old fallback.

Two review nits applied in the amended commit:

  • Reworded "failing the report" → "failing the capture" in the comment/log/test — this path also serves thumbnails and Excel exports, where nothing is a report.
  • Added match="Tiled screenshot failed for url" to both pytest.raises(PlaywrightTimeout) asserts so they stay meaningful in playwright-less environments where PlaywrightTimeout aliases bare Exception.

One pre-existing adjacent issue deliberately left out of scope: combine_screenshot_tiles still silently returns the first tile when stitching fails (screenshot_utils.py) — same silent-degradation shape, candidate for a follow-up.

713/713 unit tests in tests/unit_tests/utils/ pass post-amend; ruff check/format clean.

@eschutho

Copy link
Copy Markdown
Member Author

Does a report on a retry-enabled schedule get another attempt after this raises, or does one flaky tiled capture now fail the whole run?

Good question — traced it through the execution path. Short answer: the current run fails, but the schedule's next cron occurrence runs completely fresh, and nothing about this raise changes existing retry semantics — because there were none for captures to begin with.

Details, with the relevant code:

  • Within a run, screenshot capture has never had a retry — before or after this PR. _get_screenshots (superset/commands/report/execute.py:548-635) makes one capture attempt per screenshot and wraps any exception as ReportScheduleScreenshotFailedError. The raise this PR adds is caught by that same except Exception wrapper, so it flows through exactly the same channel as every other capture failure (element never appearing, browser crash, etc.).
  • The reports.execute Celery task has no autoretry (superset/tasks/scheduler.py:117 — plain @celery_app.task(name="reports.execute", bind=True); the autoretry_for=(Exception,) config a few lines above it belongs to the beat reports.scheduler task, which only enqueues work). The one retry knob in this area, ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES, applies to alert SQL evaluation in AlertCommand (superset/commands/report/alert.py:256), not to captures — so no configured retry is being bypassed.
  • A failed run does not wedge the schedule. ERROR is an initial state in the state machine (ReportNotTriggeredErrorState.current_states = [ReportState.NOOP, ReportState.ERROR], initial = Trueexecute.py:1189-1200), so the next cron tick starts a brand-new WORKING run and the flaky capture gets its retry then. Repeat error notifications are throttled by the schedule's grace_period (is_in_error_grace_period), so a flaky capture on a frequent schedule doesn't spam owners.

So the trade this PR makes is precisely: before, one flaky tiled capture silently delivered an unguarded (often blank/spinner) screenshot and recorded the run as SUCCESS; after, that run records ERROR with an owner notification, and the next scheduled occurrence retries fresh. If we want an in-run capture retry on top of that, it's a reasonable follow-up, but it's orthogonal to removing the unsafe fallback — and worth noting #42624 explicitly considered and deferred in-run retry ("retrying delivery is not safe without provider idempotency"; a capture-only retry wouldn't have that problem, but belongs in its own PR).

@aminghadersohi aminghadersohi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change itself is correct and I could not break it.

What I verified against the head blobs and the consumers:

  • The raise is reachable and propagates cleanly. raise PlaywrightTimeout(...) sits inside the try: at webdriver.py:473, whose except PlaywrightTimeout: raise at :661 re-raises it, and finally: context.close() at :667 still runs — no leaked browser context.
  • Both consumers already handle it. _get_screenshots() (commands/report/execute.py:620) catches it under except Exception and converts it to ReportScheduleScreenshotFailedError, i.e. the same clean report failure as the other timeout paths. compute_and_cache() (utils/screenshots.py:317) catches it and calls cache_payload.error(), so the thumbnail lands in ERROR and is retried after THUMBNAIL_ERROR_CACHE_TTL. The inline comment's "(report error, thumbnail cache ERROR)" is accurate.
  • It does not reopen the bug #41097 fixed. That PR's other half — the elif cache_payload.status != StatusValues.ERROR: cache_payload.error() branch in compute_and_cache() — is untouched, and the exception path reaches error() before it, so the payload still leaves COMPUTING and the indefinite re-trigger loop stays closed.
  • The b"" case is a real path, not a hypothetical. take_tiled_screenshot() skips every tile when the element measures scrollHeight == 0 at tile time, leaving an empty list, and combine_screenshot_tiles([]) returns b"". Previously that produced an unguarded capture; the second test covers it.
  • len(img) at :582 is safe — the raise narrows img to bytes.
  • Both tests exercise the real get_screenshot() and fail if the production change is reverted (no raise, so pytest.raises fails). Neither reaches page.screenshot/element.screenshot, and the log assertion pins the new message.
  • Security scan floor (error-message leakage, JWT, SQL/RLS, committed secrets) is clean on both changed files.

On the standing bot thread about PlaywrightTimeout misclassifying non-timeout failures: partly fair on naming, but two of its stated consequences do not hold in this repo. PlaywrightTimeout is not mapped to HTTP 408 or any status anywhere — it appears only in webdriver.py, screenshot_utils.py, and tests — and both consumers catch it under a broad except Exception, so the exception type changes no downstream classification today. The original error is also not discarded: take_tiled_screenshot() already logs it via logger.exception before returning None. What is left is a naming/type-accuracy preference, not a behavior change. Your call whether a dedicated exception is worth it.

Two non-blocking notes — one inline, one below.


Residual partial-capture fallback one frame down in combine_screenshot_tiles()

Not this PR — a follow-up question, since you've explicitly scoped screenshot_utils.py out.

combine_screenshot_tiles() still substitutes a degraded capture on failure: with two or more tiles, a PIL error logs and returns screenshot_tiles[0] (screenshot_utils.py:248-251). For a 6000px dashboard tiled at 2000px that ships the top third as if it were the whole dashboard. The value is truthy, so the new guard here accepts it and the report succeeds with a truncated image — the same user-visible outcome you're closing off one frame up.

It's deliberate and pinned by test_combine_tiles_handles_pil_error, so: is tile-1-as-fallback still the behavior you want now that the caller fails loudly on falsy results, or should it return None and let this guard fire?

Comment on lines +566 to +571
# _get_screenshot() has no wait/readiness logic at
# all, so falling back to it here would risk
# silently delivering a screenshot of spinners or
# a blank dashboard. Fail the capture loudly
# (report error, thumbnail cache ERROR) instead of
# guessing at a "safer" fallback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch has now flipped twice. #41080 removed the fallback for exactly the reason the new comment gives (blank capture silently reaching report recipients), and #41097 re-added it eleven days later while fixing the b""/COMPUTING retry loop — its description presents the fallback as the intended behavior and doesn't mention #41080.

The comment explains why there is no fallback, but not why the reason it came back no longer applies. Someone arriving from #41097 has nothing telling them not to restore it. Worth pinning the history and the reason the retry loop stays closed: compute_and_cache() catches the exception and calls cache_payload.error(), so the payload leaves COMPUTING exactly as it did when the fallback produced a falsy image.

Suggested change
# _get_screenshot() has no wait/readiness logic at
# all, so falling back to it here would risk
# silently delivering a screenshot of spinners or
# a blank dashboard. Fail the capture loudly
# (report error, thumbnail cache ERROR) instead of
# guessing at a "safer" fallback.
# _get_screenshot() has no wait/readiness logic at
# all, so falling back to it here would risk
# silently delivering a screenshot of spinners or
# a blank dashboard. Fail the capture loudly
# (report error, thumbnail cache ERROR) instead of
# guessing at a "safer" fallback.
# History: removed in #41080, re-added in #41097 to
# stop a COMPUTING-status retry loop; raising keeps
# that loop closed via compute_and_cache()'s ERROR
# transition. Re-read both before restoring it.

@eschutho
eschutho merged commit f607e17 into master Jul 31, 2026
62 checks passed
@eschutho
eschutho deleted the fix-tiled-fallback-unguarded-screenshot branch July 31, 2026 19:41
@bito-code-review

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped – PR Already Merged

Bito scheduled an automatic review for this pull request, but the review was skipped because this PR was merged before the review could be run.
No action is needed if you didn't intend to review it. To get a review, you can type /review in a comment and save it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alert-reports Namespace | Anything related to the Alert & Reports feature preset-io size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants