diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index efe4dcc4526564..fb850a56d80b41 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0001_release_models replays: 0006_add_bulk_delete_job -sentry: 0997_add_has_trace_metrics_bit_to_project_model +sentry: 0998_add_prebuilt_id_to_dashboards social_auth: 0003_social_auth_json_field diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index 551877bf13adb4..2c496fd67b71f2 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -418,6 +418,7 @@ class DashboardListResponse(TypedDict): permissions: DashboardPermissionsResponse | None isFavorited: bool projects: list[int] + prebuiltId: int | None class _WidgetPreview(TypedDict): @@ -589,6 +590,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardListResponse: "environment": attrs.get("environment", []), "filters": attrs.get("filters", {}), "lastVisited": attrs.get("last_visited", None), + "prebuiltId": obj.prebuilt_id, } @@ -611,12 +613,13 @@ class DashboardDetailsResponse(DashboardDetailsResponseOptional): id: str title: str dateCreated: str - createdBy: UserSerializerResponse + createdBy: UserSerializerResponse | None widgets: list[DashboardWidgetResponse] projects: list[int] filters: DashboardFilters permissions: DashboardPermissionsResponse | None isFavorited: bool + prebuiltId: int | None @register(Dashboard) @@ -653,13 +656,18 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardDetailsResponse: "id": str(obj.id), "title": obj.title, "dateCreated": obj.date_added, - "createdBy": user_service.serialize_many(filter={"user_ids": [obj.created_by_id]})[0], + "createdBy": ( + user_service.serialize_many(filter={"user_ids": [obj.created_by_id]})[0] + if obj.created_by_id + else None + ), "widgets": attrs["widgets"], "filters": tag_filters, "permissions": serialize(obj.permissions) if hasattr(obj, "permissions") else None, "isFavorited": user.id in obj.favorited_by, "projects": page_filters.get("projects", []), "environment": page_filters.get("environment", []), + "prebuiltId": obj.prebuilt_id, **page_filters, } diff --git a/src/sentry/apidocs/examples/dashboard_examples.py b/src/sentry/apidocs/examples/dashboard_examples.py index 7c038972315dc9..29291aac345fbd 100644 --- a/src/sentry/apidocs/examples/dashboard_examples.py +++ b/src/sentry/apidocs/examples/dashboard_examples.py @@ -77,6 +77,7 @@ "teamsWithEditAccess": [], }, "isFavorited": False, + "prebuiltId": None, } DASHBOARDS_OBJECT = [ @@ -120,6 +121,7 @@ "widgetPreview": [], "permissions": {"isEditableByEveryone": True, "teamsWithEditAccess": []}, "isFavorited": False, + "prebuiltId": None, }, { "id": "2", @@ -161,6 +163,7 @@ "widgetPreview": [], "permissions": None, "isFavorited": False, + "prebuiltId": None, }, ] diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 3bf3fa7781f4c0..f6c9ccbd71319f 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from enum import IntEnum +from typing import Any, TypedDict import sentry_sdk from django.db import IntegrityError, router, transaction @@ -46,6 +47,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.superuser import is_active_superuser from sentry.db.models.fields.text import CharField +from sentry.locks import locks from sentry.models.dashboard import Dashboard, DashboardFavoriteUser, DashboardLastVisited from sentry.models.organization import Organization from sentry.organizations.services.organization.model import ( @@ -53,10 +55,74 @@ RpcUserOrganizationContext, ) from sentry.users.services.user.service import user_service +from sentry.utils.locking import UnableToAcquireLock MAX_RETRIES = 2 +# Do not delete or modify existing entries. These enums are required to match ids in the frontend. +class PrebuiltDashboardId(IntEnum): + FRONTEND_SESSION_HEALTH = 1 + + +class PrebuiltDashboard(TypedDict): + prebuilt_id: PrebuiltDashboardId + title: str + + +# Prebuilt dashboards store minimal fields in the database. The actual dashboard and widget settings are +# coded in the frontend and we rely on matching prebuilt_id to populate the dashboard and widget display. +# Prebuilt dashboard database records are purely for tracking things like starred status, last viewed, etc. +# +# Note A: This is stored differently from the `default-overview` prebuilt dashboard, which we should +# deprecate once this feature is released. +# Note B: Consider storing all dashboard and widget data in the database instead of relying on matching +# prebuilt_id on the frontend, if there are issues. +PREBUILT_DASHBOARDS: list[PrebuiltDashboard] = [ + { + "prebuilt_id": PrebuiltDashboardId.FRONTEND_SESSION_HEALTH, + "title": "Frontend Session Health", + }, +] + + +def sync_prebuilt_dashboards(organization: Organization) -> None: + """ + Queries the database to check if prebuilt dashboards have a Dashboard record and + creates them if they don't, or deletes them if they should no longer exist. + """ + + with transaction.atomic(router.db_for_write(Dashboard)): + saved_prebuilt_dashboards = Dashboard.objects.filter( + organization=organization, + prebuilt_id__isnull=False, + ) + + saved_prebuilt_dashboard_ids = set( + saved_prebuilt_dashboards.values_list("prebuilt_id", flat=True) + ) + + # Create prebuilt dashboards if they don't exist + for prebuilt_dashboard in PREBUILT_DASHBOARDS: + prebuilt_id: PrebuiltDashboardId = prebuilt_dashboard["prebuilt_id"] + + if prebuilt_id not in saved_prebuilt_dashboard_ids: + # Create new dashboard + Dashboard.objects.create( + organization=organization, + title=prebuilt_dashboard["title"], + created_by_id=None, + prebuilt_id=prebuilt_id, + ) + + # Delete old prebuilt dashboards if they should no longer exist + prebuilt_ids = [d["prebuilt_id"] for d in PREBUILT_DASHBOARDS] + Dashboard.objects.filter( + organization=organization, + prebuilt_id__isnull=False, + ).exclude(prebuilt_id__in=prebuilt_ids).delete() + + class OrganizationDashboardsPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], @@ -127,6 +193,28 @@ def get(self, request: Request, organization: Organization) -> Response: if not features.has("organizations:dashboards-basic", organization, actor=request.user): return Response(status=404) + if features.has( + "organizations:dashboards-prebuilt-insights-dashboards", + organization, + actor=request.user, + ): + # Sync prebuilt dashboards to the database + try: + lock = locks.get( + f"dashboards:sync_prebuilt_dashboards:{organization.id}", + duration=10, + name="sync_prebuilt_dashboards", + ) + with lock.acquire(): + # Adds prebuilt dashboards to the database if they don't exist. + # Deletes old prebuilt dashboards from the database if they should no longer exist. + sync_prebuilt_dashboards(organization) + except UnableToAcquireLock: + # Another process is already syncing the prebuilt dashboards. We can skip syncing this time. + pass + except Exception as err: + sentry_sdk.capture_exception(err) + filter_by = request.query_params.get("filter") if filter_by == "onlyFavorites": dashboards = Dashboard.objects.filter( @@ -202,7 +290,13 @@ def get(self, request: Request, organization: Organization) -> Response: user_name_dict = { user.id: user.name for user in user_service.get_many_by_id( - ids=list(dashboards.values_list("created_by_id", flat=True)) + ids=list( + id + for id in dashboards.values_list("created_by_id", flat=True).filter( + created_by_id__isnull=False + ) + if id is not None + ) ) } dashboards = dashboards.annotate( diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 2ac79caf76837b..d35a1c7b150bff 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -110,6 +110,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-widget-builder-redesign", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable drilldown flow for dashboards manager.add("organizations:dashboards-drilldown-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable prebuilt dashboards for insights modules + manager.add("organizations:dashboards-prebuilt-insights-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable new timeseries visualization for dashboard widgets manager.add("organizations:dashboards-widget-timeseries-visualization", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Data Secrecy diff --git a/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py b/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py new file mode 100644 index 00000000000000..ff442f544b7fe8 --- /dev/null +++ b/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.1 on 2025-10-23 15:14 + +from django.db import migrations, models + +import sentry.db.models.fields.bounded +import sentry.db.models.fields.hybrid_cloud_foreign_key +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "0997_add_has_trace_metrics_bit_to_project_model"), + ] + + operations = [ + migrations.AddField( + model_name="dashboard", + name="prebuilt_id", + field=sentry.db.models.fields.bounded.BoundedPositiveIntegerField( + db_default=None, null=True + ), + ), + migrations.AlterField( + model_name="dashboard", + name="created_by_id", + field=sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="CASCADE" + ), + ), + migrations.AddConstraint( + model_name="dashboard", + constraint=models.UniqueConstraint( + condition=models.Q(("prebuilt_id__isnull", False)), + fields=("organization", "prebuilt_id"), + name="sentry_dashboard_organization_prebuilt_id_uniq", + ), + ), + migrations.AddConstraint( + model_name="dashboard", + constraint=models.CheckConstraint( + condition=models.Q( + ("prebuilt_id__isnull", True), ("created_by_id__isnull", True), _connector="OR" + ), + name="sentry_dashboard_prebuilt_null_created_by", + ), + ), + ] diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 743c89bd940bfa..30536cc7558cdb 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -5,7 +5,7 @@ import sentry_sdk from django.db import models, router, transaction -from django.db.models import UniqueConstraint +from django.db.models import CheckConstraint, Q, UniqueConstraint from django.db.models.query import QuerySet from django.utils import timezone @@ -13,7 +13,7 @@ from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr from sentry.db.models.base import DefaultFieldsModel -from sentry.db.models.fields.bounded import BoundedBigIntegerField +from sentry.db.models.fields.bounded import BoundedBigIntegerField, BoundedPositiveIntegerField from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.db.models.fields.jsonfield import JSONField from sentry.db.models.fields.slug import SentrySlugField @@ -223,13 +223,14 @@ class Dashboard(Model): __relocation_scope__ = RelocationScope.Organization title = models.CharField(max_length=255) - created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") + created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="CASCADE") organization = FlexibleForeignKey("sentry.Organization") date_added = models.DateTimeField(default=timezone.now) visits = BoundedBigIntegerField(null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) projects = models.ManyToManyField("sentry.Project", through=DashboardProject) filters: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) + prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None) MAX_WIDGETS = 30 @@ -237,6 +238,18 @@ class Meta: app_label = "sentry" db_table = "sentry_dashboard" unique_together = (("organization", "title"),) + constraints = [ + UniqueConstraint( + fields=["organization", "prebuilt_id"], + condition=Q(prebuilt_id__isnull=False), + name="sentry_dashboard_organization_prebuilt_id_uniq", + ), + # prebuilt dashboards cannot have a created_by_id + CheckConstraint( + condition=Q(prebuilt_id__isnull=True) | Q(created_by_id__isnull=True), + name="sentry_dashboard_prebuilt_null_created_by", + ), + ] __repr__ = sane_repr("organization", "title") diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 854945f410a67f..5b29966aebbd94 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -2349,7 +2349,7 @@ def test_update_dashboard_permissions_with_put(self) -> None: "permissions": {"isEditableByEveryone": "False"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2366,7 +2366,7 @@ def test_update_dashboard_permissions_to_false(self) -> None: "permissions": {"isEditableByEveryone": "false"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2386,7 +2386,7 @@ def test_update_dashboard_permissions_when_already_created(self) -> None: } assert permission.is_editable_by_everyone is True - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2566,7 +2566,7 @@ def test_update_dashboard_permissions_with_new_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2602,7 +2602,7 @@ def test_update_teams_in_dashboard_permissions(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2633,7 +2633,7 @@ def test_update_dashboard_permissions_with_invalid_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2660,7 +2660,7 @@ def test_update_dashboard_permissions_with_teams_from_different_org(self) -> Non self.create_environment(project=mock_project, name="mock_env") - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2706,7 +2706,7 @@ def test_select_everyone_in_dashboard_permissions_clears_all_teams(self) -> None }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index 7be61e0cca6bfa..967b761487fae1 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -6,6 +6,7 @@ from django.urls import reverse +from sentry.dashboards.endpoints.organization_dashboards import PREBUILT_DASHBOARDS from sentry.models.dashboard import ( Dashboard, DashboardFavoriteUser, @@ -1906,3 +1907,87 @@ def test_prebuilt_dashboard_is_shown_when_favorites_pinned_and_no_dashboards(sel assert response.status_code == 200, response.content assert len(response.data) == 1 assert response.data[0]["title"] == "General" + + def test_endpoint_creates_prebuilt_dashboards_when_none_exist(self) -> None: + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0 + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + prebuilt_dashboards = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ) + assert prebuilt_dashboards.count() == len(PREBUILT_DASHBOARDS) + + for prebuilt_dashboard in PREBUILT_DASHBOARDS: + dashboard = prebuilt_dashboards.get(prebuilt_id=prebuilt_dashboard["prebuilt_id"]) + assert dashboard.title == prebuilt_dashboard["title"] + assert dashboard.organization == self.organization + assert dashboard.created_by_id is None + assert dashboard.prebuilt_id == prebuilt_dashboard["prebuilt_id"] + + matching_response_data = [ + d + for d in response.data + if "prebuiltId" in d and d["prebuiltId"] == prebuilt_dashboard["prebuilt_id"] + ] + assert len(matching_response_data) == 1 + + def test_endpoint_does_not_create_duplicate_prebuilt_dashboards_when_exist(self) -> None: + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + initial_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert initial_count == len(PREBUILT_DASHBOARDS) + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + final_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert final_count == initial_count + assert final_count == len(PREBUILT_DASHBOARDS) + + def test_endpoint_deletes_old_prebuilt_dashboards_not_in_list(self) -> None: + old_prebuilt_id = 9999 # 9999 is not a valid prebuilt dashboard id + old_dashboard = Dashboard.objects.create( + organization=self.organization, + title="Old Prebuilt Dashboard", + created_by_id=None, + prebuilt_id=old_prebuilt_id, + ) + assert Dashboard.objects.filter(id=old_dashboard.id).exists() + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + assert not Dashboard.objects.filter(id=old_dashboard.id).exists() + + prebuilt_dashboards = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ) + assert prebuilt_dashboards.count() == len(PREBUILT_DASHBOARDS) + + def test_endpoint_does_not_sync_without_feature_flag(self) -> None: + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0 + + response = self.do_request("get", self.url) + assert response.status_code == 200 + + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0