Skip to content

feat(dashboards): Add pre-favorited sync for prebuilt dashboards#110209

Merged
DominikB2014 merged 4 commits intomasterfrom
dominikbuszowiecki/dain-1288-add-favourite-dashboards-sync-for-pre-starred-dashboards
Mar 9, 2026
Merged

feat(dashboards): Add pre-favorited sync for prebuilt dashboards#110209
DominikB2014 merged 4 commits intomasterfrom
dominikbuszowiecki/dain-1288-add-favourite-dashboards-sync-for-pre-starred-dashboards

Conversation

@DominikB2014
Copy link
Contributor

@DominikB2014 DominikB2014 commented Mar 9, 2026

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_favorited field is added to the PrebuiltDashboard TypedDict. When set to True, sync_prebuilt_dashboards_favorited() creates DashboardFavoriteUser records 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

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>
@linear-code
Copy link

linear-code bot commented Mar 9, 2026

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Mar 9, 2026
@DominikB2014
Copy link
Contributor Author

@cursor review

@DominikB2014
Copy link
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>
@DominikB2014 DominikB2014 marked this pull request as ready for review March 9, 2026 15:20
@DominikB2014 DominikB2014 requested a review from a team as a code owner March 9, 2026 15:20
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>
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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.

Create PR

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 = {
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

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>
@DominikB2014
Copy link
Contributor Author

@cursor review

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

@DominikB2014 DominikB2014 merged commit 6b546fd into master Mar 9, 2026
56 checks passed
@DominikB2014 DominikB2014 deleted the dominikbuszowiecki/dain-1288-add-favourite-dashboards-sync-for-pre-starred-dashboards branch March 9, 2026 17:51
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.

2 participants