Skip to content

Commit 679026e

Browse files
DominikB2014claude
andauthored
fix(slack): Apply dashboard-stored filters when unfurling widget URLs (#113711)
Pasting a dashboard widget URL into Slack produced a blank chart when the URL had no query params. The unfurl handler read `project`, `environment`, and the date range exclusively from URL params, so a bare `/dashboard/{id}/widget/{index}/` link sent an `events-timeseries` request with no project filter and returned no data. The dashboard UI itself resolves those filters from `Dashboard.projects` / `Dashboard.filters` (and its own `DEFAULT_STATS_PERIOD='24h'`) when the URL doesn't carry them. This change mirrors that precedence server-side: 1. URL query params (as before) 2. Dashboard-saved filters via `Dashboard.get_filters()` — projects, environment, period, start/end, utc, and the `all_projects` sentinel 3. Hardcoded FE defaults — `DEFAULT_PERIOD` bumped from `14d` to `24h` to match `static/app/views/dashboards/data.tsx`; project falls back to `ALL_ACCESS_PROJECT_ID` (`-1`) so unconfigured dashboards still render data instead of an empty chart Date range is treated as a unit: if the URL carries any of `statsPeriod`/`start`/`end`, the dashboard-saved range is ignored (no mixing URL `statsPeriod` with a saved `start`/`end`). localStorage-pinned `PageFilters` are deliberately not replicated — they aren't reachable from a webhook context, and dashboards pass `disablePersistence={true}` anyway. Also adds `select_related("dashboard")` to `_get_widget` so accessing `widget.dashboard.get_filters()` doesn't trigger an extra lazy query. Refs DAIN-1574 --------- Co-authored-by: Claude <[email protected]>
1 parent 2d48dec commit 679026e

2 files changed

Lines changed: 173 additions & 10 deletions

File tree

src/sentry/integrations/slack/unfurl/dashboards.py

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sentry.api.endpoints.timeseries import TimeSeries
1616
from sentry.charts import backend as charts
1717
from sentry.charts.types import ChartSize, ChartType
18+
from sentry.constants import ALL_ACCESS_PROJECT_ID
1819
from sentry.integrations.messaging.metrics import (
1920
MessagingInteractionEvent,
2021
MessagingInteractionType,
@@ -48,7 +49,10 @@ class DashboardsUnfurlArgs(TypedDict):
4849

4950
_logger = logging.getLogger(__name__)
5051

51-
DEFAULT_PERIOD = "14d"
52+
# Matches the dashboards-specific DEFAULT_STATS_PERIOD in
53+
# static/app/views/dashboards/data.tsx so unfurls show the same window the
54+
# dashboard UI does when no period is set anywhere.
55+
DEFAULT_PERIOD = "24h"
5256
TOP_N = 5
5357

5458
DASHBOARDS_CHART_SIZE: ChartSize = {"width": 1200, "height": 400}
@@ -197,6 +201,7 @@ def _get_widget(
197201
dashboard_id=dashboard_id,
198202
dashboard__organization_id=organization_id,
199203
)
204+
.select_related("dashboard")
200205
.prefetch_related(
201206
Prefetch(
202207
"dashboardwidgetquery_set",
@@ -218,8 +223,10 @@ def build_widget_timeseries_params(
218223
if dataset is None:
219224
raise ValueError(f"Unsupported widget type: {widget.widget_type}")
220225

226+
dashboard_filters = widget.dashboard.get_filters()
227+
221228
return [
222-
_params_for_widget_query(wq, url_params, dataset)
229+
_params_for_widget_query(wq, url_params, dataset, dashboard_filters)
223230
for wq in widget.dashboardwidgetquery_set.all()
224231
]
225232

@@ -228,6 +235,7 @@ def _params_for_widget_query(
228235
widget_query: DashboardWidgetQuery,
229236
url_params: QueryDict,
230237
dataset: SupportedTraceItemType,
238+
dashboard_filters: Mapping[str, Any],
231239
) -> dict[str, str | list[str]]:
232240
params: dict[str, str | list[str]] = {}
233241

@@ -250,20 +258,76 @@ def _params_for_widget_query(
250258
# when grouping without an explicit sort.
251259
params["sort"] = f"-{aggregates[0]}"
252260

253-
for param in ("project", "environment", "statsPeriod", "start", "end", "interval"):
254-
values = url_params.getlist(param)
255-
if values:
256-
params[param] = values if len(values) > 1 else values[0]
257-
258-
if "statsPeriod" not in params and "start" not in params:
259-
params["statsPeriod"] = DEFAULT_PERIOD
261+
_apply_page_filters(params, url_params, dashboard_filters)
260262

261263
params["dataset"] = dataset.value
262264
params["referrer"] = Referrer.DASHBOARDS_SLACK_UNFURL.value
263265

264266
return params
265267

266268

269+
def _apply_page_filters(
270+
params: dict[str, str | list[str]],
271+
url_params: QueryDict,
272+
dashboard_filters: Mapping[str, Any],
273+
) -> None:
274+
"""Resolve page filters (project, environment, date range, interval).
275+
276+
Precedence mirrors the dashboard UI's PageFilters resolution:
277+
URL query params -> dashboard-saved filters -> hardcoded FE defaults.
278+
localStorage-pinned filters are deliberately not replicated; they aren't
279+
reachable from a webhook context.
280+
"""
281+
# project: URL wins. Otherwise fall back to the dashboard's projects.
282+
# An unconfigured dashboard (no projects, no all_projects) falls through
283+
# to "All Projects" so the unfurl shows data instead of an empty chart.
284+
project_values = url_params.getlist("project")
285+
if not project_values:
286+
project_values = [str(p) for p in dashboard_filters.get("projects") or []]
287+
if not project_values:
288+
project_values = [str(ALL_ACCESS_PROJECT_ID)]
289+
params["project"] = project_values if len(project_values) > 1 else project_values[0]
290+
291+
# environment: URL wins. Otherwise fall back to dashboard, or omit (no
292+
# filter) to match the FE default of "All Environments".
293+
env_values = url_params.getlist("environment")
294+
if not env_values:
295+
env_values = list(dashboard_filters.get("environment") or [])
296+
if env_values:
297+
params["environment"] = env_values if len(env_values) > 1 else env_values[0]
298+
299+
# Date range: treat as a single unit. If the URL carries any date info at
300+
# all, trust it holistically (don't mix URL start with dashboard period).
301+
# ``utc`` is intentionally not forwarded: the events-timeseries endpoint
302+
# doesn't consume it, and unfurls are shared across mixed-timezone audiences.
303+
url_start = url_params.get("start")
304+
url_end = url_params.get("end")
305+
url_stats_period = url_params.get("statsPeriod")
306+
307+
if url_stats_period or url_start or url_end:
308+
if url_stats_period:
309+
params["statsPeriod"] = url_stats_period
310+
if url_start:
311+
params["start"] = url_start
312+
if url_end:
313+
params["end"] = url_end
314+
else:
315+
dash_start = dashboard_filters.get("start")
316+
dash_end = dashboard_filters.get("end")
317+
dash_period = dashboard_filters.get("period")
318+
if dash_start and dash_end:
319+
params["start"] = str(dash_start)
320+
params["end"] = str(dash_end)
321+
elif dash_period:
322+
params["statsPeriod"] = str(dash_period)
323+
else:
324+
params["statsPeriod"] = DEFAULT_PERIOD
325+
326+
interval_value = url_params.get("interval")
327+
if interval_value:
328+
params["interval"] = interval_value
329+
330+
267331
def map_dashboards_query_args(url: str, args: Mapping[str, str | None]) -> DashboardsUnfurlArgs:
268332
"""Extract the dashboard widget args and URL query params.
269333

tests/sentry/integrations/slack/test_unfurl.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2523,7 +2523,8 @@ def test_default_stats_period_when_absent(self) -> None:
25232523

25242524
params = build_widget_timeseries_params(widget, QueryDict())[0]
25252525

2526-
assert params["statsPeriod"] == "14d"
2526+
# Matches DEFAULT_STATS_PERIOD in static/app/views/dashboards/data.tsx
2527+
assert params["statsPeriod"] == "24h"
25272528

25282529
def test_url_start_end_supersedes_default_stats_period(self) -> None:
25292530
widget = self._make_widget()
@@ -2535,3 +2536,101 @@ def test_url_start_end_supersedes_default_stats_period(self) -> None:
25352536
assert "statsPeriod" not in params
25362537
assert params["start"] == "2026-01-01T00:00:00"
25372538
assert params["end"] == "2026-01-02T00:00:00"
2539+
2540+
def test_defaults_to_all_projects_when_no_url_or_dashboard_project(self) -> None:
2541+
widget = self._make_widget()
2542+
2543+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2544+
2545+
# ALL_ACCESS_PROJECT_ID (-1) so an unconfigured dashboard still renders
2546+
# data rather than an empty chart.
2547+
assert params["project"] == "-1"
2548+
2549+
def test_dashboard_projects_used_when_url_missing(self) -> None:
2550+
project_a = self.create_project(organization=self.organization)
2551+
project_b = self.create_project(organization=self.organization)
2552+
widget = self._make_widget()
2553+
widget.dashboard.projects.set([project_a, project_b])
2554+
2555+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2556+
2557+
assert sorted(params["project"]) == sorted([str(project_a.id), str(project_b.id)])
2558+
2559+
def test_dashboard_all_projects_flag_maps_to_sentinel(self) -> None:
2560+
widget = self._make_widget()
2561+
widget.dashboard.filters = {"all_projects": True}
2562+
widget.dashboard.save()
2563+
2564+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2565+
2566+
assert params["project"] == "-1"
2567+
2568+
def test_url_project_overrides_dashboard_projects(self) -> None:
2569+
project_a = self.create_project(organization=self.organization)
2570+
widget = self._make_widget()
2571+
widget.dashboard.projects.set([project_a])
2572+
2573+
params = build_widget_timeseries_params(widget, QueryDict("project=99"))[0]
2574+
2575+
assert params["project"] == "99"
2576+
2577+
def test_dashboard_environment_used_when_url_missing(self) -> None:
2578+
widget = self._make_widget()
2579+
widget.dashboard.filters = {"environment": ["prod", "staging"]}
2580+
widget.dashboard.save()
2581+
2582+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2583+
2584+
assert params["environment"] == ["prod", "staging"]
2585+
2586+
def test_url_environment_overrides_dashboard_environment(self) -> None:
2587+
widget = self._make_widget()
2588+
widget.dashboard.filters = {"environment": ["prod"]}
2589+
widget.dashboard.save()
2590+
2591+
params = build_widget_timeseries_params(widget, QueryDict("environment=dev"))[0]
2592+
2593+
assert params["environment"] == "dev"
2594+
2595+
def test_dashboard_period_used_when_url_missing(self) -> None:
2596+
widget = self._make_widget()
2597+
widget.dashboard.filters = {"period": "7d"}
2598+
widget.dashboard.save()
2599+
2600+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2601+
2602+
assert params["statsPeriod"] == "7d"
2603+
2604+
def test_dashboard_start_end_used_when_url_missing(self) -> None:
2605+
widget = self._make_widget()
2606+
widget.dashboard.filters = {
2607+
"start": "2026-01-01T00:00:00",
2608+
"end": "2026-01-02T00:00:00",
2609+
"utc": True,
2610+
}
2611+
widget.dashboard.save()
2612+
2613+
params = build_widget_timeseries_params(widget, QueryDict())[0]
2614+
2615+
assert "statsPeriod" not in params
2616+
assert params["start"] == "2026-01-01T00:00:00"
2617+
assert params["end"] == "2026-01-02T00:00:00"
2618+
# utc is intentionally dropped - not consumed by events-timeseries and
2619+
# irrelevant for a cross-timezone Slack audience.
2620+
assert "utc" not in params
2621+
2622+
def test_url_period_supersedes_dashboard_start_end(self) -> None:
2623+
# When the URL carries any date info, the dashboard's date range is
2624+
# ignored entirely so we don't mix URL statsPeriod with a saved range.
2625+
widget = self._make_widget()
2626+
widget.dashboard.filters = {
2627+
"start": "2026-01-01T00:00:00",
2628+
"end": "2026-01-02T00:00:00",
2629+
}
2630+
widget.dashboard.save()
2631+
2632+
params = build_widget_timeseries_params(widget, QueryDict("statsPeriod=7d"))[0]
2633+
2634+
assert params["statsPeriod"] == "7d"
2635+
assert "start" not in params
2636+
assert "end" not in params

0 commit comments

Comments
 (0)