feat(dashboards): Add pre-favorited sync for prebuilt dashboards#110209
Merged
DominikB2014 merged 4 commits intomasterfrom Mar 9, 2026
Conversation
Add a sync mechanism that automatically favorites certain prebuilt dashboards for users on first load. This mirrors the pattern used in explore saved queries. Changes: - Add optional `pre_favorited` field to `PrebuiltDashboard` TypedDict - Add `get_enabled_prebuilt_dashboards()` shared helper to consolidate the option/feature-flag logic used by both sync functions - Add `sync_prebuilt_dashboards_favorited()` which creates `DashboardFavoriteUser` records for pre-favorited prebuilt dashboards - Use separate locks: org-scoped for dashboard sync, user-scoped for favorite sync - Mark Backend Overview, Web Vitals, Mobile Vitals, AI Agents Overview, and MCP Overview as pre-favorited Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contributor
Author
|
@cursor review |
Contributor
Author
|
@sentry review |
…rebuilt_dashboards Move the feature flag check outside the list comprehension so it is evaluated once rather than once per dashboard entry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
edwardgou-sentry
approved these changes
Mar 9, 2026
Only sync pre-favorited prebuilt dashboards when the dashboards-sync-all-registered-prebuilt-dashboards flag is enabled, to limit the rollout until we confirm the sync works properly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Pre-favorited dashboards get re-favorited after user unfavorites
- Prebuilt dashboard unfavorites now persist as a non-favorited record and all favorite reads honor the favorited flag so sync no longer re-favorites dashboards users explicitly unfavorited.
- ✅ Fixed: Redundant dashboard re-fetch inside favoriting loop
- The pre-favoriting sync now iterates Dashboard objects directly instead of fetching IDs and re-querying each dashboard in the loop.
Or push these changes by commenting:
@cursor push cbffc1e98f
Preview (cbffc1e98f)
diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py
--- a/src/sentry/api/serializers/models/dashboard.py
+++ b/src/sentry/api/serializers/models/dashboard.py
@@ -522,7 +522,9 @@
favorited_dashboard_ids = set(
DashboardFavoriteUser.objects.filter(
- user_id=user.id, dashboard_id__in=item_dict.keys()
+ user_id=user.id,
+ favorited=True,
+ dashboard_id__in=item_dict.keys(),
).values_list("dashboard_id", flat=True)
)
diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_details.py b/src/sentry/dashboards/endpoints/organization_dashboard_details.py
--- a/src/sentry/dashboards/endpoints/organization_dashboard_details.py
+++ b/src/sentry/dashboards/endpoints/organization_dashboard_details.py
@@ -289,7 +289,21 @@
if features.has(
"organizations:dashboards-starred-reordering", organization, actor=request.user
):
- if is_favorited:
+ if dashboard.prebuilt_id is not None:
+ if is_favorited:
+ DashboardFavoriteUser.objects.insert_favorite_dashboard(
+ organization=organization,
+ user_id=request.user.id,
+ dashboard=dashboard,
+ )
+ else:
+ DashboardFavoriteUser.objects.update_favorite_dashboard(
+ organization=organization,
+ user_id=request.user.id,
+ dashboard=dashboard,
+ favorited=False,
+ )
+ elif is_favorited:
DashboardFavoriteUser.objects.insert_favorite_dashboard(
organization=organization,
user_id=request.user.id,
diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py
--- a/src/sentry/dashboards/endpoints/organization_dashboards.py
+++ b/src/sentry/dashboards/endpoints/organization_dashboards.py
@@ -13,6 +13,7 @@
IntegerField,
OrderBy,
OuterRef,
+ Q,
Subquery,
Value,
When,
@@ -310,7 +311,7 @@
return
with transaction.atomic(router.db_for_write(DashboardFavoriteUser)):
- prebuilt_dashboard_ids_without_favorite = (
+ prebuilt_dashboards_without_favorite = (
Dashboard.objects.filter(
organization=organization,
prebuilt_id__in=pre_favorited_ids,
@@ -322,13 +323,12 @@
).values_list("dashboard_id", flat=True)
)
.order_by("prebuilt_id")
- .values_list("id", flat=True)
)
- for dashboard_id in prebuilt_dashboard_ids_without_favorite:
+ for dashboard in prebuilt_dashboards_without_favorite:
DashboardFavoriteUser.objects.insert_favorite_dashboard(
organization=organization,
user_id=user_id,
- dashboard=Dashboard.objects.get(id=dashboard_id),
+ dashboard=dashboard,
)
@@ -445,9 +445,15 @@
dashboards = Dashboard.objects.filter(organization_id=organization.id)
for f in filters:
if f == "onlyFavorites":
- dashboards = dashboards.filter(dashboardfavoriteuser__user_id=request.user.id)
+ dashboards = dashboards.filter(
+ dashboardfavoriteuser__user_id=request.user.id,
+ dashboardfavoriteuser__favorited=True,
+ )
elif f == "excludeFavorites":
- dashboards = dashboards.exclude(dashboardfavoriteuser__user_id=request.user.id)
+ dashboards = dashboards.exclude(
+ dashboardfavoriteuser__user_id=request.user.id,
+ dashboardfavoriteuser__favorited=True,
+ )
elif f == "owned":
dashboards = dashboards.filter(created_by_id=request.user.id)
elif f == "shared":
@@ -564,7 +570,11 @@
"organizations:dashboards-starred-reordering", organization, actor=request.user
):
dashboards = dashboards.annotate(
- favorites_count=Count("dashboardfavoriteuser", distinct=True)
+ favorites_count=Count(
+ "dashboardfavoriteuser",
+ filter=Q(dashboardfavoriteuser__favorited=True),
+ distinct=True,
+ )
)
order_by = [
"favorites_count" if desc else "-favorites_count",
@@ -577,7 +587,9 @@
pin_by = request.query_params.get("pin")
if pin_by == "favorites":
favorited_by_subquery = DashboardFavoriteUser.objects.filter(
- dashboard=OuterRef("pk"), user_id=request.user.id
+ dashboard=OuterRef("pk"),
+ user_id=request.user.id,
+ favorited=True,
)
order_by_favorites = [
diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py
--- a/src/sentry/models/dashboard.py
+++ b/src/sentry/models/dashboard.py
@@ -46,6 +46,7 @@
organization=organization,
user_id=user_id,
position__isnull=False,
+ favorited=True,
)
.order_by("-position")
.first()
@@ -60,7 +61,7 @@
"""
Returns all favorited dashboards for a user in an organization.
"""
- return self.filter(organization=organization, user_id=user_id).order_by(
+ return self.filter(organization=organization, user_id=user_id, favorited=True).order_by(
"position", "dashboard__title"
)
@@ -70,6 +71,13 @@
"""
Returns the favorite dashboard if it exists, otherwise None.
"""
+ return self.filter(
+ organization=organization, user_id=user_id, dashboard=dashboard, favorited=True
+ ).first()
+
+ def get_favorite_dashboard_record(
+ self, organization: Organization, user_id: int, dashboard: Dashboard
+ ) -> DashboardFavoriteUser | None:
return self.filter(organization=organization, user_id=user_id, dashboard=dashboard).first()
def reorder_favorite_dashboards(
@@ -90,6 +98,7 @@
existing_favorite_dashboards = self.filter(
organization=organization,
user_id=user_id,
+ favorited=True,
)
existing_dashboard_ids = {
@@ -141,7 +150,8 @@
True if the dashboard was favorited, False if the dashboard was already favorited
"""
with transaction.atomic(using=router.db_for_write(DashboardFavoriteUser)):
- if self.get_favorite_dashboard(organization, user_id, dashboard):
+ favorite = self.get_favorite_dashboard_record(organization, user_id, dashboard)
+ if favorite and favorite.favorited:
return False
if self.count() == 0:
@@ -149,12 +159,17 @@
else:
position = self.get_last_position(organization, user_id) + 1
- self.create(
- organization=organization,
- user_id=user_id,
- dashboard=dashboard,
- position=position,
- )
+ if favorite:
+ favorite.favorited = True
+ favorite.position = position
+ favorite.save(update_fields=["favorited", "position"])
+ else:
+ self.create(
+ organization=organization,
+ user_id=user_id,
+ dashboard=dashboard,
+ position=position,
+ )
return True
def delete_favorite_dashboard(
@@ -180,11 +195,44 @@
favorite.delete()
self.filter(
- organization=organization, user_id=user_id, position__gt=deleted_position
+ organization=organization,
+ user_id=user_id,
+ favorited=True,
+ position__gt=deleted_position,
).update(position=models.F("position") - 1)
return True
+ def update_favorite_dashboard(
+ self,
+ organization: Organization,
+ user_id: int,
+ dashboard: Dashboard,
+ favorited: bool,
+ ) -> bool:
+ with transaction.atomic(using=router.db_for_write(DashboardFavoriteUser)):
+ if not (
+ favorite := self.get_favorite_dashboard_record(organization, user_id, dashboard)
+ ):
+ return False
+ deleted_position = favorite.position
+ favorite.favorited = favorited
+ favorite.position = (
+ self.get_last_position(organization, user_id) + 1 if favorited else None
+ )
+ favorite.save(update_fields=["favorited", "position"])
+
+ if not favorited and deleted_position is not None:
+ self.filter(
+ organization=organization,
+ user_id=user_id,
+ favorited=True,
+ position__gt=deleted_position,
+ ).update(position=models.F("position") - 1)
+
+ return True
+
+
@region_silo_model
class DashboardFavoriteUser(DefaultFieldsModel):
__relocation_scope__ = RelocationScope.Organization
diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py
--- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py
+++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py
@@ -2269,6 +2269,66 @@
assert response.status_code == 200
assert len(response.data) == total_count - prebuilt_dashboards_count
+ def test_prebuilt_dashboard_unfavorited_is_not_refavorited_on_subsequent_get(self) -> None:
+ with self.feature(
+ [
+ "organizations:dashboards-prebuilt-insights-dashboards",
+ "organizations:dashboards-sync-all-registered-prebuilt-dashboards",
+ "organizations:dashboards-starred-reordering",
+ ]
+ ):
+ response = self.do_request("get", self.url)
+ assert response.status_code == 200
+
+ prebuilt_dashboard = Dashboard.objects.get(
+ organization=self.organization,
+ prebuilt_id=PrebuiltDashboardId.WEB_VITALS,
+ )
+ favorite_url = reverse(
+ "sentry-api-0-organization-dashboard-favorite",
+ kwargs={
+ "organization_id_or_slug": self.organization.slug,
+ "dashboard_id": prebuilt_dashboard.id,
+ },
+ )
+ assert (
+ DashboardFavoriteUser.objects.get_favorite_dashboard(
+ organization=self.organization,
+ user_id=self.user.id,
+ dashboard=prebuilt_dashboard,
+ )
+ is not None
+ )
+
+ response = self.do_request("put", favorite_url, data={"isFavorited": False})
+ assert response.status_code == 204
+
+ assert (
+ DashboardFavoriteUser.objects.get_favorite_dashboard(
+ organization=self.organization,
+ user_id=self.user.id,
+ dashboard=prebuilt_dashboard,
+ )
+ is None
+ )
+ assert DashboardFavoriteUser.objects.filter(
+ organization=self.organization,
+ user_id=self.user.id,
+ dashboard=prebuilt_dashboard,
+ favorited=False,
+ ).exists()
+
+ response = self.do_request("get", self.url)
+ assert response.status_code == 200
+ assert (
+ DashboardFavoriteUser.objects.get_favorite_dashboard(
+ organization=self.organization,
+ user_id=self.user.id,
+ dashboard=prebuilt_dashboard,
+ )
+ is None
+ )
+
def test_post_with_text_widget(self) -> None:
with self.feature("organizations:dashboards-text-widgets"):
data = {Fetch full Dashboard objects directly instead of fetching IDs with values_list and re-querying each one individually, eliminating N+1 queries in sync_prebuilt_dashboards_favorited. Add tests covering favorite creation for pre-favorited prebuilt dashboards, idempotency, and feature flag gating. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contributor
Author
|
@cursor review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Adds a sync mechanism that automatically favorites certain prebuilt dashboards for users on first load, mirroring the pattern used in explore saved queries (
sync_prebuilt_queries_starred).An optional
pre_favoritedfield is added to thePrebuiltDashboardTypedDict. When set toTrue,sync_prebuilt_dashboards_favorited()createsDashboardFavoriteUserrecords for users who haven't interacted with those dashboards yet. This runs on the dashboards list GET endpoint with a user-scoped lock (separate from the org-scoped lock used for dashboard sync).The enabled prebuilt dashboard logic (option + feature flag check) is extracted into a shared
get_enabled_prebuilt_dashboards()helper used by both sync functions.Pre-favorited dashboards: Backend Overview, Web Vitals, Mobile Vitals, AI Agents Overview, MCP Overview.
Do not merge until #110204 merges