Browse Source

feat(performance-metrics): Add metrics vital chart (web vital details) - (#30681)

Priscila Oliveira 3 years ago
parent
commit
c68748c832

+ 0 - 4
static/app/views/performance/landing/widgets/widgets/vitalWidget.tsx

@@ -243,10 +243,6 @@ export function VitalWidget(props: PerformanceWidgetProps) {
               {...provided}
               field={field}
               vitalFields={vitalFields}
-              organization={organization}
-              query={eventView.query}
-              project={eventView.project}
-              environment={eventView.environment}
               grid={provided.grid}
             />
           ),

+ 0 - 4
static/app/views/performance/landing/widgets/widgets/vitalWidgetMetrics.tsx

@@ -206,10 +206,6 @@ export function VitalWidgetMetrics(props: PerformanceWidgetProps) {
                   mehCountField: 'meh',
                   goodCountField: 'good',
                 }}
-                organization={organization}
-                query={eventView.query}
-                project={eventView.project}
-                environment={eventView.environment}
                 grid={{
                   left: space(0),
                   right: space(0),

+ 12 - 0
static/app/views/performance/vitalDetail/types.tsx

@@ -0,0 +1,12 @@
+import EventView from 'sentry/utils/discover/eventView';
+
+const VIEW_QUERY_KEYS = [
+  'environment',
+  'project',
+  'query',
+  'start',
+  'end',
+  'statsPeriod',
+] as const;
+
+export type ViewProps = Pick<EventView, typeof VIEW_QUERY_KEYS[number]>;

+ 113 - 1
static/app/views/performance/vitalDetail/utils.tsx

@@ -1,12 +1,16 @@
 import * as React from 'react';
 import {Location, Query} from 'history';
 
+import MarkLine from 'sentry/components/charts/components/markLine';
+import {getSeriesSelection} from 'sentry/components/charts/utils';
 import {IconCheckmark, IconFire, IconWarning} from 'sentry/icons';
+import {t} from 'sentry/locale';
 import {Series} from 'sentry/types/echarts';
+import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
 import {getAggregateAlias, WebVital} from 'sentry/utils/discover/fields';
 import {TransactionMetric} from 'sentry/utils/metrics/fields';
 import {decodeScalar} from 'sentry/utils/queryString';
-import {Color} from 'sentry/utils/theme';
+import {Color, Theme} from 'sentry/utils/theme';
 
 export function generateVitalDetailRoute({orgSlug}: {orgSlug: string}): string {
   return `/organizations/${orgSlug}/performance/vitaldetail/`;
@@ -147,3 +151,111 @@ export const vitalToMetricsField: Record<string, TransactionMetric> = {
   [WebVital.FID]: TransactionMetric.SENTRY_TRANSACTIONS_MEASUREMENTS_FID,
   [WebVital.CLS]: TransactionMetric.SENTRY_TRANSACTIONS_MEASUREMENTS_CLS,
 };
+
+export function getVitalChartDefinitions({
+  theme,
+  location,
+  vital,
+  yAxis,
+}: {
+  theme: Theme;
+  location: Location;
+  vital: string;
+  yAxis: string;
+}) {
+  const utc = decodeScalar(location.query.utc) !== 'false';
+
+  const vitalPoor = webVitalPoor[vital];
+  const vitalMeh = webVitalMeh[vital];
+
+  const legend = {
+    right: 10,
+    top: 0,
+    selected: getSeriesSelection(location),
+  };
+
+  const chartOptions = {
+    grid: {
+      left: '5px',
+      right: '10px',
+      top: '35px',
+      bottom: '0px',
+    },
+    seriesOptions: {
+      showSymbol: false,
+    },
+    tooltip: {
+      trigger: 'axis' as const,
+      valueFormatter: (value: number, seriesName?: string) =>
+        tooltipFormatter(value, vital === WebVital.CLS ? seriesName : yAxis),
+    },
+    yAxis: {
+      min: 0,
+      max: vitalPoor,
+      axisLabel: {
+        color: theme.chartLabel,
+        showMaxLabel: false,
+        // coerces the axis to be time based
+        formatter: (value: number) => axisLabelFormatter(value, yAxis),
+      },
+    },
+  };
+
+  const markLines = [
+    {
+      seriesName: 'Thresholds',
+      type: 'line' as const,
+      data: [],
+      markLine: MarkLine({
+        silent: true,
+        lineStyle: {
+          color: theme.red300,
+          type: 'dashed',
+          width: 1.5,
+        },
+        label: {
+          show: true,
+          position: 'insideEndTop',
+          formatter: t('Poor'),
+        },
+        data: [
+          {
+            yAxis: vitalPoor,
+          } as any, // TODO(ts): date on this type is likely incomplete (needs @types/echarts@4.6.2)
+        ],
+      }),
+    },
+    {
+      seriesName: 'Thresholds',
+      type: 'line' as const,
+      data: [],
+      markLine: MarkLine({
+        silent: true,
+        lineStyle: {
+          color: theme.yellow300,
+          type: 'dashed',
+          width: 1.5,
+        },
+        label: {
+          show: true,
+          position: 'insideEndTop',
+          formatter: t('Meh'),
+        },
+        data: [
+          {
+            yAxis: vitalMeh,
+          } as any, // TODO(ts): date on this type is likely incomplete (needs @types/echarts@4.6.2)
+        ],
+      }),
+    },
+  ];
+
+  return {
+    vitalPoor,
+    vitalMeh,
+    legend,
+    chartOptions,
+    markLines,
+    utc,
+  };
+}

+ 30 - 130
static/app/views/performance/vitalDetail/vitalChart.tsx

@@ -1,9 +1,7 @@
 import {browserHistory, withRouter, WithRouterProps} from 'react-router';
 import {useTheme} from '@emotion/react';
-import {Location} from 'history';
 
 import ChartZoom from 'sentry/components/charts/chartZoom';
-import MarkLine from 'sentry/components/charts/components/markLine';
 import ErrorPanel from 'sentry/components/charts/errorPanel';
 import EventsRequest from 'sentry/components/charts/eventsRequest';
 import LineChart from 'sentry/components/charts/lineChart';
@@ -11,47 +9,34 @@ import ReleaseSeries from 'sentry/components/charts/releaseSeries';
 import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
 import TransitionChart from 'sentry/components/charts/transitionChart';
 import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
-import {getInterval, getSeriesSelection} from 'sentry/components/charts/utils';
 import {Panel} from 'sentry/components/panels';
 import QuestionTooltip from 'sentry/components/questionTooltip';
 import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {OrganizationSummary} from 'sentry/types';
+import {DateString, OrganizationSummary} from 'sentry/types';
 import {Series} from 'sentry/types/echarts';
-import {getUtcToLocalDateObject} from 'sentry/utils/dates';
 import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
-import EventView from 'sentry/utils/discover/eventView';
 import {WebVital} from 'sentry/utils/discover/fields';
 import getDynamicText from 'sentry/utils/getDynamicText';
-import {decodeScalar} from 'sentry/utils/queryString';
 import useApi from 'sentry/utils/useApi';
 
 import {replaceSeriesName, transformEventStatsSmoothed} from '../trends/utils';
 
+import {ViewProps} from './types';
 import {
   getMaxOfSeries,
+  getVitalChartDefinitions,
   vitalNameFromLocation,
   VitalState,
   vitalStateColors,
-  webVitalMeh,
-  webVitalPoor,
 } from './utils';
 
-const QUERY_KEYS = [
-  'environment',
-  'project',
-  'query',
-  'start',
-  'end',
-  'statsPeriod',
-] as const;
-
-type ViewProps = Pick<EventView, typeof QUERY_KEYS[number]>;
-
 type Props = WithRouterProps &
-  ViewProps & {
-    location: Location;
+  Omit<ViewProps, 'start' | 'end'> & {
     organization: OrganizationSummary;
+    start: DateString | null;
+    end: DateString | null;
+    interval: string;
   };
 
 function VitalChart({
@@ -62,13 +47,28 @@ function VitalChart({
   query,
   statsPeriod,
   router,
-  start: propsStart,
-  end: propsEnd,
+  start,
+  end,
+  interval,
 }: Props) {
   const api = useApi();
   const theme = useTheme();
 
-  const handleLegendSelectChanged = legendChange => {
+  const vitalName = vitalNameFromLocation(location);
+  const yAxis = `p75(${vitalName})`;
+
+  const {utc, legend, vitalPoor, markLines, chartOptions} = getVitalChartDefinitions({
+    theme,
+    location,
+    yAxis,
+    vital: vitalName,
+  });
+
+  function handleLegendSelectChanged(legendChange: {
+    name: string;
+    type: string;
+    selected: Record<string, boolean>;
+  }) {
     const {selected} = legendChange;
     const unselected = Object.keys(selected).filter(key => !selected[key]);
 
@@ -80,106 +80,7 @@ function VitalChart({
       },
     };
     browserHistory.push(to);
-  };
-
-  const start = propsStart ? getUtcToLocalDateObject(propsStart) : null;
-  const end = propsEnd ? getUtcToLocalDateObject(propsEnd) : null;
-  const utc = decodeScalar(router.location.query.utc) !== 'false';
-
-  const vitalName = vitalNameFromLocation(location);
-
-  const yAxis = `p75(${vitalName})`;
-
-  const legend = {
-    right: 10,
-    top: 0,
-    selected: getSeriesSelection(location),
-  };
-
-  const datetimeSelection = {
-    start,
-    end,
-    period: statsPeriod,
-  };
-
-  const vitalPoor = webVitalPoor[vitalName];
-  const vitalMeh = webVitalMeh[vitalName];
-
-  const markLines = [
-    {
-      seriesName: 'Thresholds',
-      type: 'line' as const,
-      data: [],
-      markLine: MarkLine({
-        silent: true,
-        lineStyle: {
-          color: theme.red300,
-          type: 'dashed',
-          width: 1.5,
-        },
-        label: {
-          show: true,
-          position: 'insideEndTop',
-          formatter: t('Poor'),
-        },
-        data: [
-          {
-            yAxis: vitalPoor,
-          } as any, // TODO(ts): date on this type is likely incomplete (needs @types/echarts@4.6.2)
-        ],
-      }),
-    },
-    {
-      seriesName: 'Thresholds',
-      type: 'line' as const,
-      data: [],
-      markLine: MarkLine({
-        silent: true,
-        lineStyle: {
-          color: theme.yellow300,
-          type: 'dashed',
-          width: 1.5,
-        },
-        label: {
-          show: true,
-          position: 'insideEndTop',
-          formatter: t('Meh'),
-        },
-        data: [
-          {
-            yAxis: vitalMeh,
-          } as any, // TODO(ts): date on this type is likely incomplete (needs @types/echarts@4.6.2)
-        ],
-      }),
-    },
-  ];
-
-  const chartOptions = {
-    grid: {
-      left: '5px',
-      right: '10px',
-      top: '35px',
-      bottom: '0px',
-    },
-    seriesOptions: {
-      showSymbol: false,
-    },
-    tooltip: {
-      trigger: 'axis' as const,
-      valueFormatter: (value: number, seriesName?: string) =>
-        tooltipFormatter(value, vitalName === WebVital.CLS ? seriesName : yAxis),
-    },
-    yAxis: {
-      min: 0,
-      max: vitalPoor,
-      axisLabel: {
-        color: theme.chartLabel,
-        showMaxLabel: false,
-        // coerces the axis to be time based
-        formatter: (value: number) => axisLabelFormatter(value, yAxis),
-      },
-    },
-  };
+  }
 
   return (
     <Panel>
@@ -202,7 +103,7 @@ function VitalChart({
               environment={environment}
               start={start}
               end={end}
-              interval={getInterval(datetimeSelection, 'high')}
+              interval={interval}
               showLoading={false}
               query={query}
               includePrevious={false}
@@ -280,7 +181,7 @@ function VitalChart({
 
 export default withRouter(VitalChart);
 
-export type _VitalChartProps = Props & {
+export type _VitalChartProps = {
   loading: boolean;
   reloading: boolean;
   field: string;
@@ -312,7 +213,7 @@ function fieldToVitalType(
   return undefined;
 }
 
-function __VitalChart(props: _VitalChartProps) {
+export function _VitalChart(props: _VitalChartProps) {
   const {
     field: yAxis,
     data: _results,
@@ -323,6 +224,7 @@ function __VitalChart(props: _VitalChartProps) {
     utc,
     vitalFields,
   } = props;
+
   if (!_results || !vitalFields) {
     return null;
   }
@@ -395,5 +297,3 @@ function __VitalChart(props: _VitalChartProps) {
     </div>
   );
 }
-
-export const _VitalChart = withRouter(__VitalChart);

+ 163 - 0
static/app/views/performance/vitalDetail/vitalChartMetrics.tsx

@@ -0,0 +1,163 @@
+import {browserHistory, withRouter, WithRouterProps} from 'react-router';
+import {useTheme} from '@emotion/react';
+import moment from 'moment';
+
+import ChartZoom from 'sentry/components/charts/chartZoom';
+import ErrorPanel from 'sentry/components/charts/errorPanel';
+import LineChart from 'sentry/components/charts/lineChart';
+import ReleaseSeries from 'sentry/components/charts/releaseSeries';
+import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
+import TransitionChart from 'sentry/components/charts/transitionChart';
+import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
+import {Panel} from 'sentry/components/panels';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import {IconWarning} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {DateString} from 'sentry/types';
+import {Series} from 'sentry/types/echarts';
+import {WebVital} from 'sentry/utils/discover/fields';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {MetricsRequestRenderProps} from 'sentry/utils/metrics/metricsRequest';
+
+import {replaceSeriesName, transformEventStatsSmoothed} from '../trends/utils';
+
+import {ViewProps} from './types';
+import {getMaxOfSeries, getVitalChartDefinitions} from './utils';
+
+type Props = Omit<MetricsRequestRenderProps, 'responsePrevious'> &
+  WithRouterProps &
+  Omit<ViewProps, 'query' | 'start' | 'end'> & {
+    field: string;
+    vital: WebVital;
+    start: DateString | null;
+    end: DateString | null;
+  };
+
+function VitalChartMetrics({
+  reloading,
+  loading,
+  response,
+  errored,
+  statsPeriod,
+  start,
+  end,
+  project,
+  environment,
+  field,
+  vital,
+  router,
+  location,
+}: Props) {
+  const theme = useTheme();
+
+  const {utc, legend, vitalPoor, markLines, chartOptions} = getVitalChartDefinitions({
+    theme,
+    location,
+    vital,
+    yAxis: field,
+  });
+
+  function handleLegendSelectChanged(legendChange: {
+    name: string;
+    type: string;
+    selected: Record<string, boolean>;
+  }) {
+    const {selected} = legendChange;
+    const unselected = Object.keys(selected).filter(key => !selected[key]);
+
+    const to = {
+      ...location,
+      query: {
+        ...location.query,
+        unselectedSeries: unselected,
+      },
+    };
+    browserHistory.push(to);
+  }
+
+  return (
+    <Panel>
+      <ChartContainer>
+        <HeaderTitleLegend>
+          {t('Duration p75')}
+          <QuestionTooltip
+            size="sm"
+            position="top"
+            title={t(`The durations shown should fall under the vital threshold.`)}
+          />
+        </HeaderTitleLegend>
+        <ChartZoom router={router} period={statsPeriod} start={start} end={end} utc={utc}>
+          {zoomRenderProps => {
+            if (errored) {
+              return (
+                <ErrorPanel>
+                  <IconWarning color="gray500" size="lg" />
+                </ErrorPanel>
+              );
+            }
+
+            const data = response?.groups.map(group => ({
+              seriesName: field,
+              data: response.intervals.map((intervalValue, intervalIndex) => ({
+                name: moment(intervalValue).valueOf(),
+                value: group.series[field][intervalIndex],
+              })),
+            })) as Series[] | undefined;
+
+            const colors = (data && theme.charts.getColorPalette(data.length - 2)) || [];
+            const {smoothedResults} = transformEventStatsSmoothed(data);
+
+            const smoothedSeries = smoothedResults
+              ? smoothedResults.map(({seriesName, ...rest}, i: number) => {
+                  return {
+                    seriesName: replaceSeriesName(seriesName) || 'p75',
+                    ...rest,
+                    color: colors[i],
+                    lineStyle: {
+                      opacity: 1,
+                      width: 2,
+                    },
+                  };
+                })
+              : [];
+
+            const seriesMax = getMaxOfSeries(smoothedSeries);
+            const yAxisMax = Math.max(seriesMax, vitalPoor);
+            chartOptions.yAxis.max = yAxisMax * 1.1;
+
+            return (
+              <ReleaseSeries
+                start={start}
+                end={end}
+                period={statsPeriod}
+                utc={utc}
+                projects={project}
+                environments={environment}
+              >
+                {({releaseSeries}) => (
+                  <TransitionChart loading={loading} reloading={reloading}>
+                    <TransparentLoadingMask visible={reloading} />
+                    {getDynamicText({
+                      value: (
+                        <LineChart
+                          {...zoomRenderProps}
+                          {...chartOptions}
+                          legend={legend}
+                          onLegendSelectChanged={handleLegendSelectChanged}
+                          series={[...markLines, ...releaseSeries, ...smoothedSeries]}
+                        />
+                      ),
+                      fixed: 'Web Vitals Chart',
+                    })}
+                  </TransitionChart>
+                )}
+              </ReleaseSeries>
+            );
+          }}
+        </ChartZoom>
+      </ChartContainer>
+    </Panel>
+  );
+}
+
+export default withRouter(VitalChartMetrics);

+ 58 - 33
static/app/views/performance/vitalDetail/vitalDetailContent.tsx

@@ -9,6 +9,7 @@ import Feature from 'sentry/components/acl/feature';
 import Alert from 'sentry/components/alert';
 import Button from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
+import {getInterval} from 'sentry/components/charts/utils';
 import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
 import SearchBar from 'sentry/components/events/searchBar';
 import * as Layout from 'sentry/components/layouts/thirds';
@@ -21,6 +22,7 @@ import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {Organization, Project} from 'sentry/types';
 import {generateQueryWithTag} from 'sentry/utils';
+import {getUtcToLocalDateObject} from 'sentry/utils/dates';
 import EventView from 'sentry/utils/discover/eventView';
 import {WebVital} from 'sentry/utils/discover/fields';
 import MetricsRequest from 'sentry/utils/metrics/metricsRequest';
@@ -37,6 +39,7 @@ import {getTransactionSearchQuery} from '../utils';
 import Table from './table';
 import {vitalDescription, vitalMap, vitalToMetricsField} from './utils';
 import VitalChart from './vitalChart';
+import VitalChartMetrics from './vitalChartMetrics';
 import VitalInfo from './vitalInfo';
 import VitalInfoMetrics from './vitalInfoMetrics';
 
@@ -183,19 +186,26 @@ class VitalDetailContent extends React.Component<Props, State> {
   }
 
   renderContent(vital: WebVital) {
-    const {isMetricsData, location, organization, eventView, api} = this.props;
+    const {isMetricsData, location, organization, eventView, api, projects} = this.props;
     const query = decodeScalar(location.query.query, '');
     const orgSlug = organization.slug;
     const {fields, start, end, statsPeriod, environment, project: projectIds} = eventView;
+    const localDateStart = start ? getUtcToLocalDateObject(start) : null;
+    const localDateEnd = end ? getUtcToLocalDateObject(end) : null;
+    const interval = getInterval(
+      {start: localDateStart, end: localDateEnd, period: statsPeriod},
+      'high'
+    );
 
     if (isMetricsData) {
       const field = `p75(${vitalToMetricsField[vital]})`;
+
       return (
         <React.Fragment>
           <StyledMetricsSearchBar
             searchSource="performance_vitals_metrics"
             orgSlug={orgSlug}
-            projectIds={eventView.project}
+            projectIds={projectIds}
             query={query}
             onSearch={this.handleSearch}
           />
@@ -209,15 +219,28 @@ class VitalDetailContent extends React.Component<Props, State> {
             environment={environment}
             field={[field]}
             query={new MutableSearch(query).formatString()} // TODO(metrics): not all tags will be compatible with metrics
+            interval={interval}
           >
-            {({loading: isLoading, response}) => {
+            {({loading: isLoading, response, reloading, errored}) => {
               const p75AllTransactions = response?.groups.reduce(
                 (acc, group) => acc + (group.totals[field] ?? 0),
                 0
               );
               return (
                 <React.Fragment>
-                  <div>{'TODO'}</div>
+                  <VitalChartMetrics
+                    start={localDateStart}
+                    end={localDateEnd}
+                    statsPeriod={statsPeriod}
+                    project={projectIds}
+                    environment={environment}
+                    loading={isLoading}
+                    response={response}
+                    errored={errored}
+                    reloading={reloading}
+                    field={field}
+                    vital={vital}
+                  />
                   <StyledVitalInfo>
                     <VitalInfoMetrics
                       api={api}
@@ -233,6 +256,7 @@ class VitalDetailContent extends React.Component<Props, State> {
                       isLoading={isLoading}
                     />
                   </StyledVitalInfo>
+                  <div>TODO</div>
                 </React.Fragment>
               );
             }}
@@ -241,6 +265,9 @@ class VitalDetailContent extends React.Component<Props, State> {
       );
     }
 
+    const filterString = getTransactionSearchQuery(location);
+    const summaryConditions = getSummaryConditions(filterString);
+
     return (
       <React.Fragment>
         <StyledSearchBar
@@ -256,25 +283,46 @@ class VitalDetailContent extends React.Component<Props, State> {
           query={query}
           project={projectIds}
           environment={environment}
-          start={start}
-          end={end}
+          start={localDateStart}
+          end={localDateEnd}
           statsPeriod={statsPeriod}
+          interval={interval}
         />
         <StyledVitalInfo>
           <VitalInfo location={location} vital={vital} />
         </StyledVitalInfo>
+        <Teams provideUserTeams>
+          {({teams, initiallyLoaded}) =>
+            initiallyLoaded ? (
+              <TeamKeyTransactionManager.Provider
+                organization={organization}
+                teams={teams}
+                selectedTeams={['myteams']}
+                selectedProjects={eventView.project.map(String)}
+              >
+                <Table
+                  eventView={eventView}
+                  projects={projects}
+                  organization={organization}
+                  location={location}
+                  setError={this.setError}
+                  summaryConditions={summaryConditions}
+                />
+              </TeamKeyTransactionManager.Provider>
+            ) : (
+              <LoadingIndicator />
+            )
+          }
+        </Teams>
       </React.Fragment>
     );
   }
 
   render() {
-    const {location, eventView, organization, vitalName, projects} = this.props;
+    const {location, organization, vitalName} = this.props;
     const {incompatibleAlertNotice} = this.state;
 
     const vital = vitalName || WebVital.LCP;
-
-    const filterString = getTransactionSearchQuery(location);
-    const summaryConditions = getSummaryConditions(filterString);
     const description = vitalDescription[vitalName];
 
     return (
@@ -306,29 +354,6 @@ class VitalDetailContent extends React.Component<Props, State> {
           <Layout.Main fullWidth>
             <StyledDescription>{description}</StyledDescription>
             {this.renderContent(vital)}
-            <Teams provideUserTeams>
-              {({teams, initiallyLoaded}) =>
-                initiallyLoaded ? (
-                  <TeamKeyTransactionManager.Provider
-                    organization={organization}
-                    teams={teams}
-                    selectedTeams={['myteams']}
-                    selectedProjects={eventView.project.map(String)}
-                  >
-                    <Table
-                      eventView={eventView}
-                      projects={projects}
-                      organization={organization}
-                      location={location}
-                      setError={this.setError}
-                      summaryConditions={summaryConditions}
-                    />
-                  </TeamKeyTransactionManager.Provider>
-                ) : (
-                  <LoadingIndicator />
-                )
-              }
-            </Teams>
           </Layout.Main>
         </Layout.Body>
       </React.Fragment>