Skip to content
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions src/sentry/api/serializers/models/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ class DashboardListResponse(TypedDict):
permissions: DashboardPermissionsResponse | None
isFavorited: bool
projects: list[int]
prebuiltId: int | None


class _WidgetPreview(TypedDict):
Expand Down Expand Up @@ -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,
}


Expand All @@ -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)
Expand Down Expand Up @@ -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,
}

Expand Down
3 changes: 3 additions & 0 deletions src/sentry/apidocs/examples/dashboard_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"teamsWithEditAccess": [],
},
"isFavorited": False,
"prebuiltId": None,
}

DASHBOARDS_OBJECT = [
Expand Down Expand Up @@ -120,6 +121,7 @@
"widgetPreview": [],
"permissions": {"isEditableByEveryone": True, "teamsWithEditAccess": []},
"isFavorited": False,
"prebuiltId": None,
},
{
"id": "2",
Expand Down Expand Up @@ -161,6 +163,7 @@
"widgetPreview": [],
"permissions": None,
"isFavorited": False,
"prebuiltId": None,
},
]

Expand Down
98 changes: 96 additions & 2 deletions src/sentry/dashboards/endpoints/organization_dashboards.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -46,17 +47,82 @@
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 (
RpcOrganization,
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"],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Comment on lines +295 to +298
Copy link
Member

Choose a reason for hiding this comment

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

If you're excluding none in the conditions, I don't think this filter condition will be hit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I had to add this here because mypy would still interpret id as int | None despite the filter condition.
I think this should be fine? Alternatively should we just add a type ignore? Let me know what you think!

Copy link
Member

Choose a reason for hiding this comment

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

ah ok. Sacrifices must be made to the mypy gods 🔥

)
)
}
dashboards = dashboards.annotate(
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
19 changes: 16 additions & 3 deletions src/sentry/models/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@

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

from sentry.backup.scopes import RelocationScope
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
Expand Down Expand Up @@ -223,20 +223,33 @@ 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

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")

Expand Down
Loading
Loading