Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/sentry/api/serializers/models/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class DashboardWidgetResponse(TypedDict):
dashboardId: str
queries: list[DashboardWidgetQueryResponse]
limit: int | None
widgetType: str
widgetType: str | None
layout: dict[str, int] | None
axisRange: str | None
datasetSource: str | None
Expand Down Expand Up @@ -299,10 +299,14 @@ def get_explore_urls(self, obj, attrs):
return urls

def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse:
widget_type = (
DashboardWidgetTypes.get_type_name(obj.widget_type)
or DashboardWidgetTypes.TYPE_NAMES[0]
)
# Text widgets don't have a widget_type
if obj.display_type == DashboardWidgetDisplayTypes.TEXT:
widget_type = None
else:
widget_type = (
DashboardWidgetTypes.get_type_name(obj.widget_type)
or DashboardWidgetTypes.TYPE_NAMES[0]
)

if (
obj.widget_type == DashboardWidgetTypes.DISCOVER
Expand Down Expand Up @@ -339,7 +343,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse:
"dashboardId": str(obj.dashboard_id),
"queries": attrs["queries"],
"limit": obj.limit,
# Default to discover type if null
# Default to discover type if null and not a text widget
"widgetType": widget_type,
"layout": obj.detail.get("layout") if obj.detail else None,
"axisRange": obj.detail.get("axis_range") if obj.detail else None,
Expand Down
78 changes: 67 additions & 11 deletions src/sentry/api/serializers/rest_framework/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]):
interval = serializers.CharField(required=False, max_length=10)
queries = DashboardWidgetQuerySerializer(many=True, required=False)
widget_type = serializers.ChoiceField(
choices=DashboardWidgetTypes.as_text_choices(), required=False
choices=DashboardWidgetTypes.as_text_choices(), required=False, allow_null=True
)
limit = serializers.IntegerField(min_value=1, required=False, allow_null=True)
layout = WidgetLayoutSerializer(required=False, allow_null=True)
Expand All @@ -323,8 +323,8 @@ class DashboardWidgetSerializer(CamelSnakeSerializer[Dashboard]):
def validate_display_type(self, display_type):
return DashboardWidgetDisplayTypes.get_id_for_type_name(display_type)

def validate_widget_type(self, widget_type):
widget_type = DashboardWidgetTypes.get_id_for_type_name(widget_type)
def _validate_widget_type(self, data):
widget_type = DashboardWidgetTypes.get_id_for_type_name(data.get("widget_type"))
if widget_type == DashboardWidgetTypes.DISCOVER or widget_type is None:
sentry_sdk.set_context(
"dashboard",
Expand All @@ -334,16 +334,18 @@ def validate_widget_type(self, widget_type):
)
sentry_sdk.capture_message("Created or updated widget with discover dataset.")
raise serializers.ValidationError(
"Attribute value `discover` is deprecated. Please use `error-events` or `transaction-like`"
{
"widget_type": "Attribute value `discover` is deprecated. Please use `error-events` or `transaction-like`"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Null widget_type triggers misleading discover monitoring alert

Low Severity

Adding allow_null=True to the widget_type ChoiceField makes a previously-unreachable code path in _validate_widget_type reachable. When a non-text widget sends widget_type: null, get_id_for_type_name(None) returns None, triggering the widget_type is None branch. This fires sentry_sdk.capture_message("Created or updated widget with discover dataset.") and returns an error about "discover" being deprecated — both factually incorrect for a null input. The request is still correctly rejected, but the Sentry signal is a false positive that could pollute monitoring.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

nope this is expected behaviour. No widget_type for non-text widgets will default to using the discover dataset.

)
return widget_type

validate_id = validate_id

def validate_interval(self, interval):
def _validate_interval(self, data):
interval = data.get("interval")
if parse_stats_period(interval) is None:
raise serializers.ValidationError("Invalid interval")
return interval
raise serializers.ValidationError({"interval": "Invalid interval"})

def to_internal_value(self, data):
# Update the context for the queries serializer because the display type is
Expand All @@ -361,16 +363,50 @@ def to_internal_value(self, data):
queries_serializer.context.update(additional_context)
return super().to_internal_value(data)

def _validate_text_widget(self, data):
if not features.has(
"organizations:dashboards-text-widgets",
organization=self.context["organization"],
actor=self.context["request"].user,
):
raise serializers.ValidationError({"display_type": "Text widgets are not enabled"})

if data.get("widget_type"):
raise serializers.ValidationError(
{"widget_type": "Text widgets don't have a widget type or dataset"}
)

queries = data.get("queries")
if queries and len(queries) > 0:
raise serializers.ValidationError({"queries": "Text widgets don't have queries"})

if not data.get("id"):
if not data.get("title"):
raise serializers.ValidationError({"title": "Title is required during creation."})

return data

def validate(self, data):
self.query_warnings = {"queries": [], "columns": {}}

if data.get("display_type") == DashboardWidgetDisplayTypes.TEXT:
return self._validate_text_widget(data)

query_errors = []
all_columns: set[str] = set()
has_columns = False
has_query_error = False
self.query_warnings = {"queries": [], "columns": {}}
max_cardinality_allowed = options.get("on_demand.max_widget_cardinality.on_query_count")
current_widget_specs = None
organization = self.context["organization"]

if "interval" in data:
self._validate_interval(data)
# Only validate and convert widget_type when the client sent it; otherwise keep
# missing for partial updates (use existing) or default in create_widget.
if "widget_type" in data:
data["widget_type"] = self._validate_widget_type(data)

ondemand_feature = features.has(
"organizations:on-demand-metrics-extraction-widgets", organization
)
Expand Down Expand Up @@ -823,14 +859,20 @@ def remove_missing_widgets(self, dashboard_id, keep_ids):
DashboardWidget.objects.filter(dashboard_id=dashboard_id).exclude(id__in=keep_ids).delete()

def create_widget(self, dashboard, widget_data):
is_text_widget = widget_data.get("display_type") == DashboardWidgetDisplayTypes.TEXT

widget = DashboardWidget.objects.create(
dashboard=dashboard,
display_type=widget_data["display_type"],
title=widget_data["title"],
description=widget_data.get("description", None),
thresholds=widget_data.get("thresholds", None),
interval=widget_data.get("interval", "5m"),
widget_type=widget_data.get("widget_type", DashboardWidgetTypes.ERROR_EVENTS),
widget_type=(
None
if is_text_widget
else widget_data.get("widget_type", DashboardWidgetTypes.ERROR_EVENTS)
),
discover_widget_split=widget_data.get("discover_widget_split", None),
limit=widget_data.get("limit", None),
detail={
Expand All @@ -840,6 +882,10 @@ def create_widget(self, dashboard, widget_data):
dataset_source=widget_data.get("dataset_source", DatasetSourcesTypes.USER.value),
)

# text widgets don't have queries
if is_text_widget:
return

new_queries = []
query_data_list = widget_data.pop("queries")
for i, query in enumerate(query_data_list):
Expand Down Expand Up @@ -1023,6 +1069,8 @@ def _update_or_create_field_links(
def update_widget(self, widget, data):
prev_layout = widget.detail.get("layout") if widget.detail else None
prev_axis_range = widget.detail.get("axis_range") if widget.detail else None
is_text_widget = data.get("display_type") == DashboardWidgetDisplayTypes.TEXT

widget.title = data.get("title", widget.title)
widget.description = data.get("description", widget.description)
widget.thresholds = data.get("thresholds", widget.thresholds)
Expand All @@ -1040,7 +1088,12 @@ def update_widget(self, widget, data):
"axis_range": data.get("axis_range", prev_axis_range),
}

if widget.widget_type == DashboardWidgetTypes.SPANS:
# Text widgets don't have widget_type or dataset_source
if is_text_widget:
widget.widget_type = None
widget.discover_widget_split = None
widget.dataset_source = DatasetSourcesTypes.UNKNOWN.value
elif widget.widget_type == DashboardWidgetTypes.SPANS:
if new_dataset_source == DatasetSourcesTypes.USER.value:
widget.changed_reason = None
# we don't want to reset dataset source for spans widgets in case they are part of the migration
Expand All @@ -1056,7 +1109,10 @@ def update_widget(self, widget, data):

widget.save()

if "queries" in data:
if is_text_widget:
# Text widgets don't have queries - delete all if the previous widget had queries
DashboardWidgetQuery.objects.filter(widget=widget).delete()
elif "queries" in data:
self.update_widget_queries(widget, data["queries"])

def update_widget_queries(self, widget, data):
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/models/dashboard_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ class DashboardWidgetDisplayTypes(TypesClass):
WHEEL = 10
RAGE_AND_DEAD_CLICKS = 11
SERVER_TREE = 12
TEXT = 13
TYPES = [
(LINE_CHART, "line"),
(AREA_CHART, "area"),
Expand All @@ -191,6 +192,7 @@ class DashboardWidgetDisplayTypes(TypesClass):
(WHEEL, "wheel"),
(RAGE_AND_DEAD_CLICKS, "rage_and_dead_clicks"),
(SERVER_TREE, "server_tree"),
(TEXT, "text"),
]
TYPE_NAMES = [t[1] for t in TYPES]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,25 @@ def test_add_widget_description_exceeds_max_length(self) -> None:
"Ensure description has no more than 255 characters."
]

def test_add_text_widget_description_exceeds_max_length(self) -> None:
data = {
"title": "First dashboard",
"widgets": [
{"id": str(self.widget_1.id)},
{
"title": "Widget with long description",
"displayType": "text",
"description": "x" * 256,
"interval": "5m",
"queries": [],
},
],
}
with self.feature("organizations:dashboards-text-widgets"):
response = self.do_request("put", self.url(self.dashboard.id), data=data)
assert response.status_code == 200, response.data
assert response.data["widgets"][1]["description"] == "x" * 256

def test_add_widget_with_limit(self) -> None:
data = {
"title": "First dashboard",
Expand Down Expand Up @@ -3827,6 +3846,113 @@ def test_can_edit_prebuilt_insights_dashboard_global_filters(self) -> None:
)
assert response.status_code == 200

def test_add_text_widget_to_dashboard(self) -> None:
with self.feature("organizations:dashboards-text-widgets"):
data = {
"title": "First dashboard",
"widgets": [
{"id": str(self.widget_1.id)},
{"id": str(self.widget_2.id)},
{
"title": "Text Widget",
"displayType": "text",
"description": "This is a text widget description",
},
],
}
response = self.do_request("put", self.url(self.dashboard.id), data=data)
assert response.status_code == 200, response.data

widgets = response.data["widgets"]
assert len(widgets) == 3

# Last widget should be the text widget
text_widget = widgets[2]
assert text_widget["title"] == "Text Widget"
assert text_widget["displayType"] == "text"
assert text_widget["description"] == "This is a text widget description"
assert text_widget.get("widgetType") is None

def test_add_text_widget_without_feature_flag(self) -> None:
data = {
"title": "First dashboard",
"widgets": [
{"id": str(self.widget_1.id)},
{
"title": "Text Widget",
"displayType": "text",
"description": "This is a text widget description",
},
],
}
response = self.do_request("put", self.url(self.dashboard.id), data=data)
assert response.status_code == 400, response.data
assert "widgets" in response.data, response.data
# Error is on the second widget (index 1), the new text widget; first widget has no errors
assert "Text widgets are not enabled" in response.data["widgets"][1]["displayType"][0]

def test_update_widget_to_text_widget(self) -> None:
with self.feature("organizations:dashboards-text-widgets"):
data = {
"title": "First dashboard",
"widgets": [
{
"id": str(self.widget_1.id),
"title": "Updated to Text Widget",
"displayType": "text",
"description": "Now it's a text widget",
},
{"id": str(self.widget_2.id)},
],
}
response = self.do_request("put", self.url(self.dashboard.id), data=data)
assert response.status_code == 200, response.data

widgets = response.data["widgets"]
assert len(widgets) == 2

# First widget should now be a text widget
text_widget = widgets[0]
assert text_widget["title"] == "Updated to Text Widget"
assert text_widget["displayType"] == "text"
assert text_widget["description"] == "Now it's a text widget"
assert text_widget.get("widgetType") is None
assert text_widget["queries"] == []

# Verify in database that queries were removed
widget = DashboardWidget.objects.get(id=self.widget_1.id)
assert widget.display_type == DashboardWidgetDisplayTypes.TEXT
assert widget.widget_type is None
assert DashboardWidgetQuery.objects.filter(widget=widget).count() == 0

def test_text_widget_errors_provided_queries(self) -> None:
with self.feature("organizations:dashboards-text-widgets"):
data = {
"title": "First dashboard",
"widgets": [
{"id": str(self.widget_1.id)},
{
"title": "Text Widget with Queries",
"displayType": "text",
"description": "This should ignore queries",
"queries": [
{
"name": "errors",
"conditions": "event.type:error",
"fields": ["count()"],
"columns": [],
"aggregates": ["count()"],
}
],
},
],
}
response = self.do_request("put", self.url(self.dashboard.id), data=data)
assert response.status_code == 400, response.data

assert "queries" in response.data["widgets"][1], response.data
assert response.data["widgets"][1]["queries"][0] == "Text widgets don't have queries"


class OrganizationDashboardDetailsOnDemandTest(OrganizationDashboardDetailsTestCase):
widget_type = DashboardWidgetTypes.TRANSACTION_LIKE
Expand Down
Loading
Loading