Browse Source

feat(chart-unfurls): Add chart unfurl support for world map (#29435)

Adding a World Map chart unfurl type since
it's a valid visualization on Discover. This uses
chartcuterie's init config to register the sentryWorld
map to chartcuterie's global echarts object.
Shruthi 3 years ago
parent
commit
c9cdb86ef7

+ 1 - 0
src/sentry/charts/types.py

@@ -17,3 +17,4 @@ class ChartType(Enum):
     SLACK_DISCOVER_TOP5_PERIOD_LINE = "slack:discover.top5PeriodLine"
     SLACK_DISCOVER_TOP5_DAILY = "slack:discover.top5Daily"
     SLACK_DISCOVER_PREVIOUS_PERIOD = "slack:discover.previousPeriod"
+    SLACK_DISCOVER_WORLDMAP = "slack:discover.worldmap"

+ 7 - 3
src/sentry/integrations/slack/unfurl/discover.py

@@ -28,6 +28,7 @@ display_modes: Mapping[str, ChartType] = {
     "top5line": ChartType.SLACK_DISCOVER_TOP5_PERIOD_LINE,
     "dailytop5": ChartType.SLACK_DISCOVER_TOP5_DAILY,
     "previous": ChartType.SLACK_DISCOVER_PREVIOUS_PERIOD,
+    "worldmap": ChartType.SLACK_DISCOVER_WORLDMAP,
 }
 
 # All `multiPlotType: line` fields in /static/app/utils/discover/fields.tsx
@@ -183,17 +184,20 @@ def unfurl_discover(
                 stats_period = get_double_period(stats_period)
                 params.setlist("statsPeriod", [stats_period])
 
+        endpoint = "events-stats/"
+        if "worldmap" in display_mode:
+            endpoint = "events-geo/"
+
         try:
             resp = client.get(
                 auth=ApiKey(organization=org, scope_list=["org:read"]),
                 user=user,
-                path=f"/organizations/{org_slug}/events-stats/",
+                path=f"/organizations/{org_slug}/{endpoint}",
                 params=params,
             )
         except Exception as exc:
             logger.error(
-                "Failed to load events-stats for unfurl: %s",
-                str(exc),
+                f"Failed to load {endpoint} for unfurl: {exc}",
                 exc_info=True,
             )
             continue

+ 13 - 0
src/sentry/web/frontend/debug/debug_chart_renderer.py

@@ -31,6 +31,17 @@ discover_total_period = {
     },
 }
 
+discover_geo = {
+    "seriesName": "Discover total period",
+    "stats": {
+        "data": [
+            {"geo.country_code": "US", "count": 1},
+            {"geo.country_code": "GB", "count": 30},
+            {"geo.country_code": "AU", "count": 20},
+        ],
+    },
+}
+
 discover_total_daily = {
     "seriesName": "Discover total daily",
     "stats": {
@@ -285,6 +296,8 @@ class DebugChartRendererView(View):
         charts.append(generate_chart(ChartType.SLACK_DISCOVER_TOP5_PERIOD_LINE, discover_empty))
         charts.append(generate_chart(ChartType.SLACK_DISCOVER_TOP5_DAILY, discover_top5))
         charts.append(generate_chart(ChartType.SLACK_DISCOVER_TOP5_DAILY, discover_empty))
+        charts.append(generate_chart(ChartType.SLACK_DISCOVER_WORLDMAP, discover_geo))
+        charts.append(generate_chart(ChartType.SLACK_DISCOVER_WORLDMAP, discover_empty))
         charts.append(
             generate_chart(ChartType.SLACK_DISCOVER_PREVIOUS_PERIOD, discover_total_period)
         )

+ 5 - 0
static/app/chartcuterie/config.tsx

@@ -8,6 +8,8 @@
  * into the configuration file loaded by the service.
  */
 
+import * as worldMap from 'app/data/world.json';
+
 import {discoverCharts} from './discover';
 import {ChartcuterieConfig, ChartType, RenderConfig, RenderDescriptor} from './types';
 
@@ -21,6 +23,9 @@ const renderConfig: RenderConfig<ChartType> = new Map();
  */
 const config: ChartcuterieConfig = {
   version: process.env.COMMIT_SHA!,
+  init: echarts => {
+    echarts.registerMap('sentryWorld', worldMap);
+  },
   renderConfig,
 };
 

+ 56 - 3
static/app/chartcuterie/discover.tsx

@@ -1,16 +1,19 @@
-import {EChartOption} from 'echarts/lib/echarts';
+import {EChartOption} from 'echarts';
 import isArray from 'lodash/isArray';
+import max from 'lodash/max';
 
 import XAxis from 'app/components/charts/components/xAxis';
 import AreaSeries from 'app/components/charts/series/areaSeries';
 import BarSeries from 'app/components/charts/series/barSeries';
 import LineSeries from 'app/components/charts/series/lineSeries';
+import MapSeries from 'app/components/charts/series/mapSeries';
 import {lightenHexToRgb} from 'app/components/charts/utils';
+import * as countryCodesMap from 'app/data/countryCodesMap';
 import {t} from 'app/locale';
-import {EventsStats} from 'app/types';
+import {EventsGeoData, EventsStats} from 'app/types';
 import {lightTheme as theme} from 'app/utils/theme';
 
-import {slackChartDefaults, slackChartSize} from './slack';
+import {slackChartDefaults, slackChartSize, slackGeoChartSize} from './slack';
 import {ChartType, RenderDescriptor} from './types';
 
 const discoverxAxis = XAxis({
@@ -405,3 +408,53 @@ discoverCharts.push({
   },
   ...slackChartSize,
 });
+
+discoverCharts.push({
+  key: ChartType.SLACK_DISCOVER_WORLDMAP,
+  getOption: (data: {seriesName: string; stats: {data: EventsGeoData}}) => {
+    const mapSeries = MapSeries({
+      map: 'sentryWorld',
+      name: data.seriesName,
+      data: data.stats.data.map(country => ({
+        name: country['geo.country_code'],
+        value: country.count,
+      })),
+      nameMap: countryCodesMap.default,
+      aspectScale: 0.85,
+      zoom: 1.1,
+      center: [10.97, 9.71],
+      itemStyle: {
+        areaColor: theme.gray200,
+        borderColor: theme.backgroundSecondary,
+      } as any, // TODO(ts): Echarts types aren't correct for these colors as they don't allow for basic strings
+    });
+
+    // For absolute values, we want min/max to based on min/max of series
+    // Otherwise it should be 0-100
+    const maxValue = max(data.stats.data.map(value => value.count)) || 1;
+
+    return {
+      backgroundColor: theme.background,
+      visualMap: [
+        {
+          left: 'right',
+          min: 0,
+          max: maxValue,
+          inRange: {
+            color: [theme.purple200, theme.purple300],
+          },
+          text: ['High', 'Low'],
+          textStyle: {
+            color: theme.textColor,
+          },
+
+          // Whether show handles, which can be dragged to adjust "selected range".
+          // False because the handles are pretty ugly
+          calculable: false,
+        },
+      ],
+      series: [mapSeries],
+    };
+  },
+  ...slackGeoChartSize,
+});

+ 5 - 0
static/app/chartcuterie/slack.tsx

@@ -12,6 +12,11 @@ export const slackChartSize = {
   width: 450,
 };
 
+export const slackGeoChartSize = {
+  height: 200,
+  width: 450,
+};
+
 /**
  * Default echarts option config for slack charts
  */

+ 13 - 0
static/app/chartcuterie/types.tsx

@@ -14,6 +14,7 @@ export enum ChartType {
   SLACK_DISCOVER_TOP5_PERIOD_LINE = 'slack:discover.top5PeriodLine',
   SLACK_DISCOVER_TOP5_DAILY = 'slack:discover.top5Daily',
   SLACK_DISCOVER_PREVIOUS_PERIOD = 'slack:discover.previousPeriod',
+  SLACK_DISCOVER_WORLDMAP = 'slack:discover.worldmap',
 }
 
 /**
@@ -69,6 +70,13 @@ export type RenderData = {
   data: any;
 };
 
+/**
+ * Performs any additional initialization steps on Chartcuterie's global
+ * echarts object on service start up. For example, registerMaps can
+ * be called here to register any available maps to ECharts.
+ */
+export type InitFn = (echarts: any) => void;
+
 /**
  * The configuration object type expected to be provided to the service
  */
@@ -80,6 +88,11 @@ export type ChartcuterieConfig = {
    * configuration.
    */
   version: string;
+  /**
+   * The optional initialization function to run when the service starts
+   * or restarts due to configuration updates.
+   */
+  init?: InitFn;
 };
 
 /**

+ 1 - 0
static/app/types/index.tsx

@@ -450,6 +450,7 @@ export type ProjectSdkUpdates = {
 };
 
 export type EventsStatsData = [number, {count: number; comparisonCount?: number}[]][];
+export type EventsGeoData = {'geo.country_code': string; count: number}[];
 
 // API response format for a single series
 export type EventsStats = {

+ 49 - 0
tests/sentry/integrations/slack/test_unfurl.py

@@ -494,3 +494,52 @@ class UnfurlTest(TestCase):
         chart_data = mock_generate_chart.call_args[0][1]
         assert chart_data["seriesName"] == "count()"
         assert len(chart_data["stats"]["data"]) == 288
+
+    @patch("sentry.integrations.slack.unfurl.discover.generate_chart", return_value="chart-url")
+    def test_unfurl_world_map(self, mock_generate_chart):
+        min_ago = iso_format(before_now(minutes=1))
+        self.store_event(
+            data={
+                "fingerprint": ["group2"],
+                "timestamp": min_ago,
+                "user": {"geo": {"country_code": "CA", "region": "Canada"}},
+            },
+            project_id=self.project.id,
+        )
+        self.store_event(
+            data={
+                "fingerprint": ["group2"],
+                "timestamp": min_ago,
+                "user": {"geo": {"country_code": "AU", "region": "Australia"}},
+            },
+            project_id=self.project.id,
+        )
+
+        url = f"https://sentry.io/organizations/{self.organization.slug}/discover/results/?display=worldmap&field=count()&name=All+Events&project={self.project.id}&query=&statsPeriod=24h"
+        link_type, args = match_link(url)
+
+        if not args or not link_type:
+            raise Exception("Missing link_type/args")
+
+        links = [
+            UnfurlableUrl(url=url, args=args),
+        ]
+
+        with self.feature(
+            [
+                "organizations:discover-basic",
+                "organizations:chart-unfurls",
+            ]
+        ):
+            unfurls = link_handlers[link_type].fn(self.request, self.integration, links, self.user)
+
+        assert unfurls[url] == build_discover_attachment(
+            title=args["query"].get("name"), chart_url="chart-url"
+        )
+        assert len(mock_generate_chart.mock_calls) == 1
+
+        assert mock_generate_chart.call_args[0][0] == ChartType.SLACK_DISCOVER_WORLDMAP
+        chart_data = mock_generate_chart.call_args[0][1]
+        assert chart_data["seriesName"] == "count()"
+        assert len(chart_data["stats"]["data"]) == 2
+        assert sorted(x["geo.country_code"] for x in chart_data["stats"]["data"]) == ["AU", "CA"]