Skip to content

Commit 2655cbe

Browse files
DominikB2014claude
authored andcommitted
feat(slack): Unfurl Explore Traces URLs with chart previews (#112020)
Add Slack URL unfurling support for `/explore/traces/` pages. When a user pastes an Explore Traces URL in Slack, it now renders a chart preview — the same way Discover URLs already do. The implementation parses `aggregateField` JSON query params from the URL to extract `yAxes` and `groupBy`, then calls the `events-stats` endpoint with `dataset=spans` and renders the chart using existing Discover chart types via Chartcuterie. Please note: Fully gated behind the existing `organizations:data-browsing-widget-unfurl` feature flag, with user-level actor checks so it can be safely tested per-user in production. Going to follow up with a bunch of cleanup, but wanted to test this behind a flag as it's much easier and more accurate in prod Refs DAIN-1438 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 02e6956 commit 2655cbe

File tree

10 files changed

+669
-5
lines changed

10 files changed

+669
-5
lines changed

src/sentry/api/endpoints/organization_events_stats.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
Referrer.API_ENDPOINT_REGRESSION_ALERT_CHARTCUTERIE.value,
5454
Referrer.API_FUNCTION_REGRESSION_ALERT_CHARTCUTERIE.value,
5555
Referrer.DISCOVER_SLACK_UNFURL.value,
56+
Referrer.EXPLORE_SLACK_UNFURL.value,
5657
]
5758

5859
logger = logging.getLogger(__name__)

src/sentry/integrations/messaging/metrics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class MessagingInteractionType(StrEnum):
4444
UNFURL_ISSUES = "UNFURL_ISSUES"
4545
UNFURL_METRIC_ALERTS = "UNFURL_METRIC_ALERTS"
4646
UNFURL_DISCOVER = "UNFURL_DISCOVER"
47+
UNFURL_EXPLORE = "UNFURL_EXPLORE"
4748

4849
GET_PARENT_NOTIFICATION = "GET_PARENT_NOTIFICATION"
4950

src/sentry/integrations/slack/requests/event.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ def has_discover_links(links: list[str]) -> bool:
1414
return any(match_link(link)[0] == LinkType.DISCOVER for link in links)
1515

1616

17+
def has_explore_links(links: list[str]) -> bool:
18+
return any(match_link(link)[0] == LinkType.EXPLORE for link in links)
19+
20+
1721
def is_event_challenge(data: Mapping[str, Any]) -> bool:
1822
return data.get("type", "") == "url_verification"
1923

@@ -79,7 +83,8 @@ def validate_integration(self) -> None:
7983
super().validate_integration()
8084

8185
if (self.text in COMMANDS) or (
82-
self.type == "link_shared" and has_discover_links(self.links)
86+
self.type == "link_shared"
87+
and (has_discover_links(self.links) or has_explore_links(self.links))
8388
):
8489
self._validate_identity()
8590

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
from __future__ import annotations
2+
3+
import html
4+
import logging
5+
import re
6+
from collections.abc import Mapping
7+
from typing import Any
8+
from urllib.parse import urlparse
9+
10+
from django.http.request import QueryDict
11+
12+
from sentry import analytics, features
13+
from sentry.api import client
14+
from sentry.charts import backend as charts
15+
from sentry.charts.types import ChartType
16+
from sentry.integrations.messaging.metrics import (
17+
MessagingInteractionEvent,
18+
MessagingInteractionType,
19+
)
20+
from sentry.integrations.models.integration import Integration
21+
from sentry.integrations.services.integration import RpcIntegration, integration_service
22+
from sentry.integrations.slack.analytics import SlackIntegrationChartUnfurl
23+
from sentry.integrations.slack.message_builder.discover import SlackDiscoverMessageBuilder
24+
from sentry.integrations.slack.spec import SlackMessagingSpec
25+
from sentry.integrations.slack.unfurl.types import Handler, UnfurlableUrl, UnfurledUrl
26+
from sentry.models.apikey import ApiKey
27+
from sentry.models.organization import Organization
28+
from sentry.snuba.referrer import Referrer
29+
from sentry.users.models.user import User
30+
from sentry.users.services.user import RpcUser
31+
from sentry.utils import json
32+
33+
_logger = logging.getLogger(__name__)
34+
35+
DEFAULT_PERIOD = "14d"
36+
DEFAULT_Y_AXIS = "count(span.duration)"
37+
38+
# All `multiPlotType: line` fields in /static/app/utils/discover/fields.tsx
39+
LINE_PLOT_FIELDS = {
40+
"count_unique",
41+
"min",
42+
"max",
43+
"p50",
44+
"p75",
45+
"p90",
46+
"p95",
47+
"p99",
48+
"p100",
49+
"percentile",
50+
"avg",
51+
}
52+
53+
TOP_N = 5
54+
55+
56+
def _serialize_single_series(series: dict[str, Any]) -> dict[str, Any]:
57+
"""Convert a single TimeSeries into events-stats format."""
58+
values = series.get("values", [])
59+
data = []
60+
for row in values:
61+
# events-timeseries uses milliseconds, events-stats uses seconds
62+
timestamp = int(row["timestamp"] / 1000)
63+
data.append((timestamp, [{"count": row.get("value", 0)}]))
64+
65+
start = int(values[0]["timestamp"] / 1000) if values else 0
66+
end = int(values[-1]["timestamp"] / 1000) if values else 0
67+
68+
return {
69+
"data": data,
70+
"start": start,
71+
"end": end,
72+
"isMetricsData": False,
73+
}
74+
75+
76+
def timeseries_to_chart_data(
77+
resp_data: dict[str, Any], y_axis: str, has_groups: bool = False
78+
) -> dict[str, Any]:
79+
"""
80+
Converts an events-timeseries StatsResponse into the events-stats format
81+
that Chartcuterie expects.
82+
83+
For single series:
84+
{"data": [(timestamp_sec, [{"count": N}]), ...], "start": sec, "end": sec}
85+
86+
For top events (grouped):
87+
{"group_label": {"data": [...], "order": N, ...}, ...}
88+
"""
89+
time_series = resp_data.get("timeSeries", [])
90+
matching = [ts for ts in time_series if ts.get("yAxis") == y_axis]
91+
92+
if not matching:
93+
return {"data": [], "start": 0, "end": 0, "isMetricsData": False}
94+
95+
if has_groups:
96+
# Top events: return dict keyed by group label
97+
result = {}
98+
for i, ts in enumerate(matching):
99+
group_by = ts.get("groupBy", [])
100+
label = ",".join(str(g.get("value", "")) for g in group_by) if group_by else str(i)
101+
series_data = _serialize_single_series(ts)
102+
series_data["order"] = ts.get("meta", {}).get("order", i)
103+
result[label] = series_data
104+
return result
105+
106+
return _serialize_single_series(matching[0])
107+
108+
109+
def unfurl_explore(
110+
integration: Integration | RpcIntegration,
111+
links: list[UnfurlableUrl],
112+
user: User | RpcUser | None = None,
113+
) -> UnfurledUrl:
114+
with MessagingInteractionEvent(
115+
MessagingInteractionType.UNFURL_EXPLORE, SlackMessagingSpec(), user=user
116+
).capture() as lifecycle:
117+
lifecycle.add_extras({"integration_id": integration.id})
118+
return _unfurl_explore(integration, links, user)
119+
120+
121+
def _unfurl_explore(
122+
integration: Integration | RpcIntegration,
123+
links: list[UnfurlableUrl],
124+
user: User | RpcUser | None = None,
125+
) -> UnfurledUrl:
126+
org_integrations = integration_service.get_organization_integrations(
127+
integration_id=integration.id
128+
)
129+
organizations = Organization.objects.filter(
130+
id__in=[oi.organization_id for oi in org_integrations]
131+
)
132+
orgs_by_slug = {org.slug: org for org in organizations}
133+
134+
# Check if any org has the feature flag enabled before doing any work
135+
enabled_orgs = {
136+
slug: org
137+
for slug, org in orgs_by_slug.items()
138+
if features.has("organizations:data-browsing-widget-unfurl", org, actor=user)
139+
}
140+
if not enabled_orgs:
141+
return {}
142+
143+
unfurls = {}
144+
145+
for link in links:
146+
org_slug = link.args["org_slug"]
147+
org = enabled_orgs.get(org_slug)
148+
149+
if not org:
150+
continue
151+
152+
params = link.args["query"]
153+
154+
y_axes = params.getlist("yAxis")
155+
if not y_axes:
156+
y_axes = [DEFAULT_Y_AXIS]
157+
params.setlist("yAxis", y_axes)
158+
159+
group_bys = params.getlist("groupBy")
160+
161+
# Only one yAxis is charted; multiple charts per unfurl not yet supported.
162+
if group_bys:
163+
aggregate_fn = y_axes[-1].split("(")[0]
164+
if aggregate_fn in LINE_PLOT_FIELDS:
165+
style = ChartType.SLACK_DISCOVER_TOP5_PERIOD_LINE
166+
else:
167+
style = ChartType.SLACK_DISCOVER_TOP5_PERIOD
168+
params.setlist("topEvents", [str(TOP_N)])
169+
else:
170+
style = ChartType.SLACK_DISCOVER_TOTAL_PERIOD
171+
172+
if not params.get("statsPeriod") and not params.get("start"):
173+
params["statsPeriod"] = DEFAULT_PERIOD
174+
175+
params["dataset"] = "spans"
176+
params["referrer"] = Referrer.EXPLORE_SLACK_UNFURL.value
177+
178+
try:
179+
resp = client.get(
180+
auth=ApiKey(organization_id=org.id, scope_list=["org:read"]),
181+
user=user,
182+
path=f"/organizations/{org_slug}/events-timeseries/",
183+
params=params,
184+
)
185+
except Exception:
186+
_logger.warning("Failed to load events-timeseries for explore unfurl")
187+
continue
188+
189+
# QueryDict.items() sends only the last value per key to the API,
190+
# so we must match that by charting the last yAxis
191+
y_axis = y_axes[-1]
192+
stats = timeseries_to_chart_data(resp.data, y_axis, has_groups=bool(group_bys))
193+
chart_data = {"seriesName": y_axis, "stats": stats}
194+
195+
try:
196+
url = charts.generate_chart(style, chart_data)
197+
except RuntimeError:
198+
_logger.warning("Failed to generate chart for explore unfurl")
199+
continue
200+
201+
unfurls[link.url] = SlackDiscoverMessageBuilder(
202+
title="Explore Traces",
203+
chart_url=url,
204+
).build()
205+
206+
first_org_integration = org_integrations[0] if len(org_integrations) > 0 else None
207+
if first_org_integration is not None and hasattr(first_org_integration, "id"):
208+
analytics.record(
209+
SlackIntegrationChartUnfurl(
210+
organization_id=first_org_integration.organization_id,
211+
user_id=user.id if user else None,
212+
unfurls_count=len(unfurls),
213+
)
214+
)
215+
216+
return unfurls
217+
218+
219+
def map_explore_query_args(url: str, args: Mapping[str, str | None]) -> Mapping[str, Any]:
220+
"""
221+
Extracts explore arguments from the explore link's query string.
222+
Parses aggregateField JSON params to extract yAxes and groupBy.
223+
"""
224+
# Slack uses HTML escaped ampersands in its Event Links
225+
url = html.unescape(url)
226+
parsed_url = urlparse(url)
227+
raw_query = QueryDict(parsed_url.query)
228+
229+
# Parse aggregateField JSON params
230+
aggregate_fields = raw_query.getlist("aggregateField")
231+
y_axes: list[str] = []
232+
group_bys: list[str] = []
233+
for field_json in aggregate_fields:
234+
try:
235+
parsed = json.loads(field_json)
236+
if "yAxes" in parsed and isinstance(parsed["yAxes"], list):
237+
y_axes.extend(parsed["yAxes"])
238+
if "groupBy" in parsed and parsed["groupBy"]:
239+
group_bys.append(parsed["groupBy"])
240+
except (json.JSONDecodeError, TypeError):
241+
continue
242+
243+
if not y_axes:
244+
y_axes = [DEFAULT_Y_AXIS]
245+
246+
# Build query params
247+
query = QueryDict(mutable=True)
248+
query.setlist("yAxis", y_axes)
249+
250+
if group_bys:
251+
query.setlist("groupBy", group_bys)
252+
253+
# Copy standard params
254+
for param in ("project", "statsPeriod", "start", "end", "query", "environment"):
255+
values = raw_query.getlist(param)
256+
if values:
257+
query.setlist(param, values)
258+
259+
return dict(**args, query=query)
260+
261+
262+
explore_traces_link_regex = re.compile(
263+
r"^https?\://(?#url_prefix)[^/]+/organizations/(?P<org_slug>[^/]+)/explore/traces/"
264+
)
265+
266+
customer_domain_explore_traces_link_regex = re.compile(
267+
r"^https?\://(?P<org_slug>[^.]+?)\.(?#url_prefix)[^/]+/explore/traces/"
268+
)
269+
270+
explore_handler = Handler(
271+
fn=unfurl_explore,
272+
matcher=[
273+
explore_traces_link_regex,
274+
customer_domain_explore_traces_link_regex,
275+
],
276+
arg_mapper=map_explore_query_args,
277+
)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
from typing import Any
33

44
from sentry.integrations.slack.unfurl.discover import discover_handler
5+
from sentry.integrations.slack.unfurl.explore import explore_handler
56
from sentry.integrations.slack.unfurl.issues import issues_handler
67
from sentry.integrations.slack.unfurl.metric_alerts import metric_alert_handler
78
from sentry.integrations.slack.unfurl.types import Handler, LinkType
89

910
link_handlers: dict[LinkType, Handler] = {
11+
LinkType.EXPLORE: explore_handler,
1012
LinkType.DISCOVER: discover_handler,
1113
LinkType.METRIC_ALERT: metric_alert_handler,
1214
LinkType.ISSUES: issues_handler,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class LinkType(enum.Enum):
1818
ISSUES = "issues"
1919
METRIC_ALERT = "metric_alert"
2020
DISCOVER = "discover"
21+
EXPLORE = "explore"
2122

2223

2324
class UnfurlableUrl(NamedTuple):

src/sentry/integrations/slack/webhooks/event.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,16 @@ def _get_unfurlable_links(
195195
if link_type is None or args is None:
196196
continue
197197

198+
feature_flag = {
199+
LinkType.DISCOVER: "organizations:discover-basic",
200+
LinkType.EXPLORE: "organizations:data-browsing-widget-unfurl",
201+
}.get(link_type)
202+
198203
if (
199204
organization
200-
and link_type == LinkType.DISCOVER
205+
and feature_flag
201206
and not slack_request.has_identity
202-
and features.has(
203-
"organizations:discover-basic", organization, actor=request.user
204-
)
207+
and features.has(feature_flag, organization, actor=request.user)
205208
):
206209
try:
207210
analytics.record(

src/sentry/snuba/referrer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,7 @@ class Referrer(StrEnum):
625625
DELETIONS_GROUP = "deletions.group"
626626
DISCOVER = "discover"
627627
DISCOVER_SLACK_UNFURL = "discover.slack.unfurl"
628+
EXPLORE_SLACK_UNFURL = "explore.slack.unfurl"
628629
DYNAMIC_SAMPLING_COUNTERS_GET_ORG_TRANSACTION_VOLUMES = (
629630
"dynamic_sampling.counters.get_org_transaction_volumes"
630631
)

0 commit comments

Comments
 (0)