Skip to content

fix(dashboards): Don't 500 when validating with an empty project list - #120998

Open
skaasten wants to merge 2 commits into
masterfrom
shaunkaasten/dain-1803-seer-fails-to-create-a-dashboard-due-to-missing-project-ids
Open

fix(dashboards): Don't 500 when validating with an empty project list#120998
skaasten wants to merge 2 commits into
masterfrom
shaunkaasten/dain-1803-seer-fails-to-create-a-dashboard-due-to-missing-project-ids

Conversation

@skaasten

@skaasten skaasten commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes SENTRY-44CF, and the frontend error it surfaces as, JAVASCRIPT-39NM (DAIN-1803).

Problem

POST /organizations/{org}/dashboards/ returned a 500 whenever the resolved project list was empty:

UnqualifiedQueryError: You need to specify at least one project with data.
  File "sentry/api/serializers/rest_framework/dashboard.py", line 332, in validate
    builder.where = builder.resolve_params()
  File "sentry/search/events/builder/base.py", line 593, in resolve_params

Widget query validation builds its params from context["projects"], which comes from get_projects(request, organization). With no project query param that filters by team membership (auth/access.py:181) — but has_project_access also grants access via has_global_access (allow_joinleave OR role.is_global). So a teamless member of an open-membership org resolves to [] despite being entitled to every project.

resolve_params then raised UnqualifiedQueryError, which subclasses SnubaError rather than InvalidSearchQuery, so none of the existing except clauses caught it.

This isn't specific to the Seer flow in the linked issue — five call sites share this serializer, and both the create and edit endpoints were affected.

Fix

Two layers in DashboardWidgetQuerySerializer.validate:

  1. _get_validation_project_ids() — when the context list is empty, borrow any single active project in the organization. Widget queries are validated for syntactic correctness, which doesn't depend on project scope; the list only exists to satisfy the query builder. Mirrors the existing workaround in dashboards/on_completion_hook.py:83-87.

  2. except UnqualifiedQueryError — if no project exists at all, return a validation error instead of a 500.

resolve_params itself is untouched — it's widely shared and its empty-project guard is intentional.

Notes for review

  • The catch is UnqualifiedQueryError, not SnubaError, with a fixed message. Sibling types carry Snuba/ClickHouse internals that shouldn't reach API consumers, and infrastructure failures like RateLimitExceeded shouldn't surface as field validation errors.
  • The borrowed project is scoped to the request's organization and no query runs against it.
  • The fallback is not an access check. It doesn't distinguish why the list was empty, so closed-membership teamless users now validate successfully where they previously got a 500. Widget data is still fetched later through endpoints that apply their own project permissions. If those users shouldn't create dashboards at all, that belongs in the generate endpoint — happy to file a follow-up.

Testing

Three regression tests, all confirmed to fail against the parent commit:

  • teamless member — the membership route
  • teamless member of an open-membership org — asserts 201 on a real create, the case a backstop-only fix would have blocked
  • organization with no projects — the backstop, asserting the exact message so the exception text can't be reintroduced

resolve_params only raises outside the test environment (base.py:592), so these patch in_test_environment. Without that patch they pass against unfixed code — worth knowing if they're ever edited.

Verification
Case Org has project User in team Before After
control yes yes 200 200
teamless member yes no 500 200 (fallback)
org with no projects no yes 500 400 (backstop)
both no no 500 400
$ pytest tests/sentry/dashboards/ tests/sentry/api/serializers/rest_framework/
438 passed, 1 skipped

$ mypy src/sentry/api/serializers/rest_framework/dashboard.py
Success: no issues found in 1 source file

🤖 Generated with Claude Code

Widget query validation built the query params from the serializer's
`projects` context. That list is empty whenever a request reaches the
endpoint without a `project` query param and `get_projects` resolves to
nothing, since it then filters by team membership. `resolve_params` raised
`UnqualifiedQueryError`, which subclasses `SnubaError` rather than
`InvalidSearchQuery`, so none of the existing `except` clauses caught it
and it surfaced as a 500.

Widget queries are validated for syntactic correctness, which doesn't
depend on which projects are in scope — the project list only exists to
satisfy the query builder. Fall back to any active project in the
organization when the context list is empty, mirroring the workaround
already used in `dashboards/on_completion_hook.py`, and catch
`UnqualifiedQueryError` so a request can't 500 even when no fallback
project exists.

The new handler catches `UnqualifiedQueryError` specifically rather than
`SnubaError`, and reports a fixed message instead of the exception text.
Sibling `SnubaError` types (`SchemaValidationError`, the ClickHouse code
map, `QueryConnectionFailed`) carry Snuba and ClickHouse internals that
should not reach API consumers, and infrastructure failures such as
`RateLimitExceeded` should not be reported as field validation errors.

Observed on both the create and details endpoints, across several
organizations. The exact reason the resolved list was empty varies per
request and isn't recorded in the events, so this fixes the failure mode
rather than any single precondition.

Fixes SENTRY-44CF

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@skaasten
skaasten requested a review from a team as a code owner July 31, 2026 19:32
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

DAIN-1803

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jul 31, 2026
Comment thread src/sentry/api/serializers/rest_framework/dashboard.py Outdated
_get_validation_project_ids runs once per widget query, so an empty
project context triggered the same fallback lookup up to MAX_WIDGETS
times per request. Cache it in the serializer context, which DRF shares
across the serializer tree for the life of the request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant