Browse Source

feat(starfish): Introduce new slow screens widget to perf overview (#60322)

Adds an "Average TTIDs" widget to the mobile view. I needed to add a new
widget because of the reliance on the release hook, I couldn't inject it
into the queries for the existing list widgets. This also gives us more
control over the series we're displaying as well as the styling.
Nar Saynorath 1 year ago
parent
commit
7a20e6da7b

+ 3 - 0
static/app/views/performance/landing/views/mobileView.tsx

@@ -39,6 +39,9 @@ export function MobileView(props: BasePerformanceViewProps) {
       ]
     );
   }
+  if (organization.features.includes('performance-screens-view')) {
+    doubleRowAllowedCharts[0] = PerformanceWidgetSetting.SLOW_SCREENS_BY_TTID;
+  }
   if (
     organization.features.includes('performance-new-trends') &&
     canUseMetricsData(props.organization)

+ 3 - 0
static/app/views/performance/landing/widgets/components/widgetContainer.tsx

@@ -21,6 +21,7 @@ import {usePerformanceDisplayType} from 'sentry/utils/performance/contexts/perfo
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import withOrganization from 'sentry/utils/withOrganization';
+import SlowScreens from 'sentry/views/performance/landing/widgets/widgets/slowScreens';
 
 import {GenericPerformanceWidgetDataType} from '../types';
 import {_setChartSetting, filterAllowedChartsMetrics, getChartSetting} from '../utils';
@@ -202,6 +203,8 @@ function _WidgetContainer(props: Props) {
       return <StackedAreaChartListWidget {...passedProps} {...widgetProps} />;
     case GenericPerformanceWidgetDataType.PERFORMANCE_SCORE:
       return <PerformanceScoreWidget {...passedProps} {...widgetProps} />;
+    case GenericPerformanceWidgetDataType.SLOW_SCREENS_BY_TTID:
+      return <SlowScreens {...passedProps} {...widgetProps} />;
     default:
       throw new Error(`Widget type "${widgetProps.dataType}" has no implementation.`);
   }

+ 1 - 0
static/app/views/performance/landing/widgets/types.tsx

@@ -24,6 +24,7 @@ export enum GenericPerformanceWidgetDataType {
   TRENDS = 'trends',
   STACKED_AREA = 'stacked_area',
   PERFORMANCE_SCORE = 'performance_score',
+  SLOW_SCREENS_BY_TTID = 'slow_screens_by_ttid',
 }
 
 export type PerformanceWidgetProps = {

+ 8 - 0
static/app/views/performance/landing/widgets/widgetDefinitions.tsx

@@ -66,6 +66,7 @@ export enum PerformanceWidgetSetting {
   TIME_TO_FULL_DISPLAY = 'time_to_full_display',
   OVERALL_PERFORMANCE_SCORE = 'overall_performance_score',
   MOST_TIME_CONSUMING_RESOURCES = 'most_time_consuming_resources',
+  SLOW_SCREENS_BY_TTID = 'slow_screens_by_ttid',
 }
 
 const WIDGET_PALETTE = CHART_PALETTE[5];
@@ -388,4 +389,11 @@ export const WIDGET_DEFINITIONS: ({
     fields: SPAN_OP_BREAKDOWN_FIELDS.map(spanOp => `p75(${spanOp})`),
     dataType: GenericPerformanceWidgetDataType.STACKED_AREA,
   },
+  [PerformanceWidgetSetting.SLOW_SCREENS_BY_TTID]: {
+    title: t('Average TTIDs'),
+    titleTooltip: '',
+    subTitle: t('Top screens by count'),
+    fields: ['avg(measurements.time_to_initial_display)'],
+    dataType: GenericPerformanceWidgetDataType.SLOW_SCREENS_BY_TTID,
+  },
 });

+ 399 - 0
static/app/views/performance/landing/widgets/widgets/slowScreens.tsx

@@ -0,0 +1,399 @@
+import {Fragment, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import pick from 'lodash/pick';
+
+import Accordion from 'sentry/components/accordion/accordion';
+import {LinkButton} from 'sentry/components/button';
+import EventsRequest, {RenderProps} from 'sentry/components/charts/eventsRequest';
+import {getInterval} from 'sentry/components/charts/utils';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import Truncate from 'sentry/components/truncate';
+import {CHART_PALETTE} from 'sentry/constants/chartPalette';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Series, SeriesDataUnit} from 'sentry/types/echarts';
+import {defined} from 'sentry/utils';
+import {tooltipFormatterUsingAggregateOutputType} from 'sentry/utils/discover/charts';
+import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {formatVersion} from 'sentry/utils/formatters';
+import {useMEPSettingContext} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
+import {usePageError} from 'sentry/utils/performance/contexts/pageError';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {GenericPerformanceWidget} from 'sentry/views/performance/landing/widgets/components/performanceWidget';
+import {
+  GrowLink,
+  WidgetEmptyStateWarning,
+} from 'sentry/views/performance/landing/widgets/components/selectableList';
+import {transformDiscoverToList} from 'sentry/views/performance/landing/widgets/transforms/transformDiscoverToList';
+import {transformEventsRequestToArea} from 'sentry/views/performance/landing/widgets/transforms/transformEventsToArea';
+import {
+  PerformanceWidgetProps,
+  QueryDefinition,
+  QueryDefinitionWithKey,
+  WidgetDataConstraint,
+  WidgetDataResult,
+  WidgetPropUnion,
+} from 'sentry/views/performance/landing/widgets/types';
+import {
+  eventsRequestQueryProps,
+  getMEPParamsIfApplicable,
+  QUERY_LIMIT_PARAM,
+  TOTAL_EXPANDABLE_ROWS_HEIGHT,
+} from 'sentry/views/performance/landing/widgets/utils';
+import {Subtitle} from 'sentry/views/profiling/landing/styles';
+import {RightAlignedCell} from 'sentry/views/replays/deadRageClick/deadRageSelectorCards';
+import Chart from 'sentry/views/starfish/components/chart';
+import {useReleaseSelection} from 'sentry/views/starfish/queries/useReleases';
+import {STARFISH_CHART_INTERVAL_FIDELITY} from 'sentry/views/starfish/utils/constants';
+import {appendReleaseFilters} from 'sentry/views/starfish/utils/releaseComparison';
+import {OUTPUT_TYPE, YAxis, YAXIS_COLUMNS} from 'sentry/views/starfish/views/screens';
+
+type DataType = {
+  chart: WidgetDataResult & ReturnType<typeof transformEventsRequestToArea>;
+  list: WidgetDataResult & ReturnType<typeof transformDiscoverToList>;
+};
+
+export function transformEventsChartRequest<T extends WidgetDataConstraint>(
+  widgetProps: WidgetPropUnion<T>,
+  results: RenderProps,
+  _: QueryDefinitionWithKey<T>
+) {
+  const {start, end, utc, interval, statsPeriod} = normalizeDateTimeParams(
+    widgetProps.location.query
+  );
+
+  const data = results.results ?? [];
+
+  const childData = {
+    ...results,
+    isLoading: results.loading || results.reloading,
+    isErrored: results.errored,
+    hasData: defined(data) && !!data.length && !!data[0].data.length,
+    data,
+    previousData: results.previousTimeseriesData ?? undefined,
+
+    utc: utc === 'true',
+    interval,
+    statsPeriod: statsPeriod ?? undefined,
+    start: start ?? '',
+    end: end ?? '',
+  };
+
+  return childData;
+}
+
+function SlowScreensByTTID(props: PerformanceWidgetProps) {
+  const api = useApi();
+  const pageFilter = usePageFilters();
+  const mepSetting = useMEPSettingContext();
+  const {
+    isLoading: isLoadingReleases,
+    primaryRelease,
+    secondaryRelease,
+  } = useReleaseSelection();
+  const location = useLocation();
+  const [selectedListIndex, setSelectListIndex] = useState<number>(0);
+  const {organization, InteractiveTitle} = props;
+  const pageError = usePageError();
+
+  const field = props.fields[0];
+
+  const listQuery = useMemo<QueryDefinition<DataType, WidgetDataResult>>(
+    () => ({
+      fields: field,
+      component: provided => {
+        if (isLoadingReleases) {
+          return null;
+        }
+
+        const eventView = provided.eventView.clone();
+        let extraQueryParams = getMEPParamsIfApplicable(mepSetting, props.chartSetting);
+
+        // Set fields
+        eventView.fields = [
+          {field: 'transaction'},
+          {field: YAXIS_COLUMNS[YAxis.TTID]},
+          {field: 'count()'},
+        ];
+        eventView.sorts = [
+          {
+            field: 'count()',
+            kind: 'desc',
+          },
+        ];
+
+        // Change data set to metrics
+        eventView.dataset = DiscoverDatasets.METRICS;
+        extraQueryParams = {
+          ...extraQueryParams,
+          dataset: DiscoverDatasets.METRICS,
+        };
+
+        // Update query
+        const mutableSearch = new MutableSearch(eventView.query);
+        mutableSearch.addFilterValue('event.type', 'transaction');
+        mutableSearch.addFilterValue('transaction.op', 'ui.load');
+        eventView.query = appendReleaseFilters(
+          mutableSearch,
+          primaryRelease,
+          secondaryRelease
+        );
+
+        return (
+          <DiscoverQuery
+            {...provided}
+            eventView={eventView}
+            location={location}
+            limit={QUERY_LIMIT_PARAM}
+            cursor="0:0:1"
+            noPagination
+            queryExtras={extraQueryParams}
+          />
+        );
+      },
+      transform: transformDiscoverToList,
+    }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [props.chartSetting, mepSetting.memoizationKey, primaryRelease, secondaryRelease]
+  );
+
+  const chartQuery = useMemo<QueryDefinition<DataType, WidgetDataResult>>(
+    () => {
+      return {
+        enabled: widgetData => {
+          return !!widgetData?.list?.data?.length;
+        },
+        fields: field,
+        component: provided => {
+          const eventView = props.eventView.clone();
+          let extraQueryParams = getMEPParamsIfApplicable(mepSetting, props.chartSetting);
+          const pageFilterDatetime = {
+            start: provided.start,
+            end: provided.end,
+            period: provided.period,
+          };
+
+          // Chart options
+          const currentSeriesNames = [field];
+          const includePreviousParam = false;
+          const yAxis = provided.yAxis;
+          const interval = getInterval(pageFilterDatetime, 'medium');
+          const partialDataParam = true;
+
+          eventView.additionalConditions.setFilterValues('transaction', [
+            provided.widgetData.list.data[selectedListIndex].transaction as string,
+          ]);
+
+          eventView.dataset = DiscoverDatasets.METRICS;
+          extraQueryParams = {
+            ...extraQueryParams,
+            dataset: DiscoverDatasets.METRICS,
+          };
+
+          eventView.fields = [
+            {field: 'avg(measurements.time_to_initial_display)'},
+            {field: 'release'},
+          ];
+          const mutableSearch = new MutableSearch(eventView.query);
+          mutableSearch.addFilterValue('event.type', 'transaction');
+          mutableSearch.addFilterValue('transaction.op', 'ui.load');
+          eventView.query = appendReleaseFilters(
+            mutableSearch,
+            primaryRelease,
+            secondaryRelease
+          );
+          eventView.interval = getInterval(
+            pageFilter.selection.datetime,
+            STARFISH_CHART_INTERVAL_FIDELITY
+          );
+
+          return (
+            <EventsRequest
+              {...pick(provided, eventsRequestQueryProps)}
+              api={api}
+              yAxis={yAxis}
+              includePrevious={includePreviousParam}
+              includeTransformedData
+              partial={partialDataParam}
+              currentSeriesNames={currentSeriesNames}
+              field={eventView.getFields()}
+              query={eventView.getQueryWithAdditionalConditions()}
+              interval={interval}
+              hideError
+              onError={pageError.setPageError}
+              queryExtras={extraQueryParams}
+              topEvents={2}
+              referrer="performance-line-chart-widget"
+            />
+          );
+        },
+        transform: transformEventsChartRequest,
+      };
+    },
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [
+      props.chartSetting,
+      selectedListIndex,
+      mepSetting.memoizationKey,
+      primaryRelease,
+      secondaryRelease,
+    ]
+  );
+
+  const Queries = {
+    list: listQuery,
+    chart: chartQuery,
+  };
+
+  const assembleAccordionItems = provided =>
+    getItems(provided).map(item => ({header: item, content: getChart(provided)}));
+
+  const getChart = provided =>
+    function () {
+      const transformedReleaseSeries: {
+        [releaseVersion: string]: Series;
+      } = {};
+
+      const series = provided.widgetData.chart.data;
+      if (defined(series)) {
+        series.forEach(({seriesName: release, data}) => {
+          const isPrimary = release === primaryRelease;
+
+          const label = release;
+          const seriesData =
+            data.map(datum => {
+              return {
+                name: datum.name,
+                value: datum.value,
+              } as SeriesDataUnit;
+            }) ?? [];
+
+          const color = isPrimary ? CHART_PALETTE[3][0] : CHART_PALETTE[3][1];
+          transformedReleaseSeries[release] = {
+            seriesName: formatVersion(label),
+            color,
+            data: seriesData,
+          };
+        });
+      }
+
+      return (
+        <Chart
+          height={props.chartHeight}
+          data={Object.values(transformedReleaseSeries)}
+          loading={provided.widgetData.chart.isLoading}
+          utc={false}
+          grid={{
+            left: '0',
+            right: '0',
+            top: '8px',
+            bottom: '0',
+          }}
+          isLineChart
+          aggregateOutputFormat={OUTPUT_TYPE[YAxis.TTID]}
+          tooltipFormatterOptions={{
+            valueFormatter: value =>
+              tooltipFormatterUsingAggregateOutputType(value, OUTPUT_TYPE[YAxis.TTID]),
+          }}
+          errored={provided.widgetData.chart.isErrored}
+          disableXAxis
+          showLegend={false}
+        />
+      );
+    };
+
+  const getItems = provided =>
+    provided.widgetData.list.data.map(
+      listItem =>
+        function () {
+          const transaction = (listItem.transaction as string | undefined) ?? '';
+
+          return (
+            <Fragment>
+              <GrowLink
+                to={normalizeUrl({
+                  pathname: `/performance/mobile/screens/spans/`,
+                  query: {
+                    project: listItem['project.id'],
+                    transaction,
+                    primaryRelease,
+                    secondaryRelease,
+                    ...normalizeDateTimeParams(location.query),
+                  },
+                })}
+              >
+                <Truncate value={transaction} maxLength={40} />
+              </GrowLink>
+              <RightAlignedCell>
+                <StyledDurationWrapper>
+                  <PerformanceDuration
+                    milliseconds={listItem['avg(measurements.time_to_initial_display)']}
+                    abbreviation
+                  />
+                </StyledDurationWrapper>
+              </RightAlignedCell>
+            </Fragment>
+          );
+        }
+    );
+
+  const Visualizations = [
+    {
+      component: provided =>
+        isLoadingReleases || provided.widgetData.chart.loading ? (
+          <LoadingIndicator />
+        ) : (
+          <Accordion
+            expandedIndex={selectedListIndex}
+            setExpandedIndex={setSelectListIndex}
+            items={assembleAccordionItems(provided)}
+          />
+        ),
+      // accordion items height + chart height
+      height: TOTAL_EXPANDABLE_ROWS_HEIGHT + props.chartHeight,
+      noPadding: true,
+    },
+  ];
+
+  return (
+    <GenericPerformanceWidget
+      {...props}
+      location={location}
+      Subtitle={() => <Subtitle>{props.subTitle}</Subtitle>}
+      HeaderActions={() => (
+        <LinkButton
+          to={normalizeUrl({
+            pathname: `/organizations/${organization.slug}/performance/mobile/screens/`,
+            query: {
+              ...normalizeDateTimeParams(pageFilter),
+              primaryRelease,
+              secondaryRelease,
+            },
+          })}
+          size="sm"
+        >
+          {t('View All')}
+        </LinkButton>
+      )}
+      InteractiveTitle={
+        InteractiveTitle ? () => <InteractiveTitle isLoading={false} /> : null
+      }
+      EmptyComponent={WidgetEmptyStateWarning}
+      Queries={Queries}
+      Visualizations={Visualizations}
+    />
+  );
+}
+
+export default SlowScreensByTTID;
+
+const StyledDurationWrapper = styled('div')`
+  padding: 0 ${space(1)};
+`;