Browse Source

feat(performance-metrics): Add metrics series data widget [INGEST-680] (#30361)

Priscila Oliveira 3 years ago
parent
commit
70f06df9dd

+ 11 - 0
static/app/components/charts/utils.tsx

@@ -170,6 +170,17 @@ export function canIncludePreviousPeriod(
   return !!includePrevious;
 }
 
+export function shouldFetchPreviousPeriod({
+  includePrevious = true,
+  period,
+  start,
+  end,
+}: {
+  includePrevious?: boolean;
+} & Pick<DateTimeObject, 'start' | 'end' | 'period'>) {
+  return !start && !end && canIncludePreviousPeriod(includePrevious, period);
+}
+
 /**
  * Generates a series selection based on the query parameters defined by the location.
  */

+ 58 - 12
static/app/utils/metrics/metricsRequest.tsx

@@ -4,9 +4,11 @@ import omitBy from 'lodash/omitBy';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {Client} from 'sentry/api';
-import {getInterval} from 'sentry/components/charts/utils';
+import {getInterval, shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
+import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
 import {t} from 'sentry/locale';
 import {DateString, MetricsApiResponse, Organization} from 'sentry/types';
+import {getPeriod} from 'sentry/utils/getPeriod';
 
 const propNamesToIgnore = ['api', 'children'];
 const omitIgnoredProps = (props: Props) =>
@@ -17,9 +19,17 @@ export type MetricsRequestRenderProps = {
   reloading: boolean;
   errored: boolean;
   response: MetricsApiResponse | null;
+  responsePrevious: MetricsApiResponse | null;
 };
 
-type Props = {
+type DefaultProps = {
+  /**
+   * Include data for previous period
+   */
+  includePrevious: boolean;
+};
+
+type Props = DefaultProps & {
   api: Client;
   organization: Organization;
   field: string[];
@@ -41,13 +51,19 @@ type State = {
   reloading: boolean;
   errored: boolean;
   response: MetricsApiResponse | null;
+  responsePrevious: MetricsApiResponse | null;
 };
 
 class MetricsRequest extends React.Component<Props, State> {
+  static defaultProps: DefaultProps = {
+    includePrevious: false,
+  };
+
   state: State = {
     reloading: false,
     errored: false,
     response: null,
+    responsePrevious: null,
   };
 
   componentDidMount() {
@@ -74,7 +90,7 @@ class MetricsRequest extends React.Component<Props, State> {
     return `/organizations/${organization.slug}/metrics/data/`;
   }
 
-  get baseQueryParams() {
+  baseQueryParams({previousPeriod = false} = {}) {
     const {
       project,
       environment,
@@ -89,23 +105,40 @@ class MetricsRequest extends React.Component<Props, State> {
       interval,
     } = this.props;
 
-    return {
+    const commonQuery = {
       project,
       environment,
       field,
-      statsPeriod,
       query: query || undefined,
       groupBy,
       orderBy,
       limit,
-      start: start ?? undefined,
-      end: end ?? undefined,
       interval: interval ? interval : getInterval({start, end, period: statsPeriod}),
     };
+
+    if (!previousPeriod) {
+      return {
+        ...commonQuery,
+        statsPeriod,
+        start: start ?? undefined,
+        end: end ?? undefined,
+      };
+    }
+
+    const doubledStatsPeriod = getPeriod(
+      {period: statsPeriod, start: undefined, end: undefined},
+      {shouldDoublePeriod: true}
+    ).statsPeriod;
+
+    return {
+      ...commonQuery,
+      statsPeriodStart: doubledStatsPeriod,
+      statsPeriodEnd: statsPeriod ?? DEFAULT_STATS_PERIOD,
+    };
   }
 
   fetchData = async () => {
-    const {api, isDisabled} = this.props;
+    const {api, isDisabled, start, end, statsPeriod, includePrevious} = this.props;
 
     if (isDisabled) {
       return;
@@ -116,10 +149,21 @@ class MetricsRequest extends React.Component<Props, State> {
       errored: false,
     }));
 
+    const promises = [api.requestPromise(this.path, {query: this.baseQueryParams()})];
+
+    if (shouldFetchPreviousPeriod({start, end, period: statsPeriod, includePrevious})) {
+      promises.push(
+        api.requestPromise(this.path, {
+          query: this.baseQueryParams({previousPeriod: true}),
+        })
+      );
+    }
+
     try {
-      const response: MetricsApiResponse = await api.requestPromise(this.path, {
-        query: this.baseQueryParams,
-      });
+      const [response, responsePrevious] = (await Promise.all(promises)) as [
+        MetricsApiResponse,
+        MetricsApiResponse | undefined
+      ];
 
       if (this.unmounting) {
         return;
@@ -128,6 +172,7 @@ class MetricsRequest extends React.Component<Props, State> {
       this.setState({
         reloading: false,
         response,
+        responsePrevious: responsePrevious ?? null,
       });
     } catch (error) {
       addErrorMessage(error.responseJSON?.detail ?? t('Error loading metrics data'));
@@ -139,7 +184,7 @@ class MetricsRequest extends React.Component<Props, State> {
   };
 
   render() {
-    const {reloading, errored, response} = this.state;
+    const {reloading, errored, response, responsePrevious} = this.state;
     const {children, isDisabled} = this.props;
 
     const loading = response === null && !isDisabled;
@@ -149,6 +194,7 @@ class MetricsRequest extends React.Component<Props, State> {
       reloading,
       errored,
       response,
+      responsePrevious,
     });
   }
 }

+ 16 - 2
static/app/views/performance/landing/widgets/components/widgetContainer.tsx

@@ -19,6 +19,7 @@ import {PerformanceWidgetSetting, WIDGET_DEFINITIONS} from '../widgetDefinitions
 import {HistogramWidget} from '../widgets/histogramWidget';
 import {LineChartListWidget} from '../widgets/lineChartListWidget';
 import {SingleFieldAreaWidget} from '../widgets/singleFieldAreaWidget';
+import {SingleFieldAreaWidgetMetrics} from '../widgets/singleFieldAreaWidgetMetrics';
 import {TrendsWidget} from '../widgets/trendsWidget';
 import {VitalWidget} from '../widgets/vitalWidget';
 import {VitalWidgetMetrics} from '../widgets/vitalWidgetMetrics';
@@ -98,7 +99,8 @@ const _WidgetContainer = (props: Props) => {
     setChartSettingState(_chartSetting);
   }, [rest.defaultChartSetting]);
 
-  const chartDefinition = WIDGET_DEFINITIONS({organization})[chartSetting];
+  const widgetDefinitions = WIDGET_DEFINITIONS({organization});
+  const chartDefinition = widgetDefinitions[chartSetting];
   const widgetProps = {
     ...chartDefinition,
     chartSetting,
@@ -116,7 +118,10 @@ const _WidgetContainer = (props: Props) => {
 
   if (
     isMetricsData &&
-    ![GenericPerformanceWidgetDataType.vitals].includes(widgetProps.dataType)
+    ![
+      GenericPerformanceWidgetDataType.vitals,
+      GenericPerformanceWidgetDataType.area,
+    ].includes(widgetProps.dataType)
   ) {
     return <h3>{t('todo')}</h3>;
   }
@@ -132,6 +137,15 @@ const _WidgetContainer = (props: Props) => {
     case GenericPerformanceWidgetDataType.trends:
       return <TrendsWidget {...passedProps} {...widgetProps} />;
     case GenericPerformanceWidgetDataType.area:
+      if (isMetricsData) {
+        return (
+          <SingleFieldAreaWidgetMetrics
+            {...passedProps}
+            {...widgetProps}
+            widgetDefinitions={widgetDefinitions}
+          />
+        );
+      }
       return <SingleFieldAreaWidget {...passedProps} {...widgetProps} />;
     case GenericPerformanceWidgetDataType.vitals:
       if (isMetricsData) {

+ 78 - 0
static/app/views/performance/landing/widgets/transforms/transformMetricsToArea.tsx

@@ -0,0 +1,78 @@
+import mean from 'lodash/mean';
+import moment from 'moment';
+
+import {getParams} from 'sentry/components/organizations/globalSelectionHeader/getParams';
+import {defined} from 'sentry/utils';
+import {axisLabelFormatter} from 'sentry/utils/discover/charts';
+import {aggregateOutputType} from 'sentry/utils/discover/fields';
+import {MetricsRequestRenderProps} from 'sentry/utils/metrics/metricsRequest';
+
+import {QueryDefinitionWithKey, WidgetDataConstraint, WidgetPropUnion} from '../types';
+
+export function transformMetricsToArea<T extends WidgetDataConstraint>(
+  widgetProps: WidgetPropUnion<T>,
+  results: MetricsRequestRenderProps,
+  _: QueryDefinitionWithKey<T>
+) {
+  const {start, end, utc, interval, statsPeriod} = getParams(widgetProps.location.query);
+
+  const {errored, loading, reloading, response, responsePrevious} = results;
+
+  const metricsField = widgetProps.fields[0];
+
+  const data =
+    response?.groups.map(group => {
+      return {
+        seriesName: metricsField,
+        data: response.intervals.map((intervalValue, intervalIndex) => {
+          return {
+            name: moment(intervalValue).valueOf(),
+            value: group.series[metricsField][intervalIndex],
+          };
+        }),
+      };
+    }) ?? [];
+
+  const dataMean = data.map(series => {
+    const meanData = mean(series.data.map(({value}) => value));
+    return {
+      mean: meanData,
+      outputType: aggregateOutputType(series.seriesName),
+      label: axisLabelFormatter(meanData, series.seriesName),
+    };
+  });
+
+  const previousData =
+    response &&
+    responsePrevious?.groups.map(group => {
+      return {
+        seriesName: `previous ${metricsField}`,
+        data: response.intervals.map((intervalValue, intervalIndex) => {
+          return {
+            name: moment(intervalValue).valueOf(),
+            value: group.series[metricsField][intervalIndex],
+          };
+        }),
+        stack: 'previous',
+      };
+    });
+
+  const childData = {
+    loading,
+    reloading,
+    isLoading: loading || reloading,
+    isErrored: errored,
+    hasData: defined(data) && !!data.length && !!data[0].data.length,
+    data,
+    dataMean,
+    previousData: previousData ?? undefined,
+
+    utc: utc === 'true',
+    interval,
+    statsPeriod: statsPeriod ?? undefined,
+    start: start ?? '',
+    end: end ?? '',
+  };
+
+  return childData;
+}

+ 3 - 3
static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx

@@ -106,13 +106,13 @@ export function SingleFieldAreaWidget(props: PerformanceWidgetProps) {
 }
 
 const EventsRequest = withApi(_EventsRequest);
-const DurationChart = withRouter(_DurationChart);
-const Subtitle = styled('span')`
+export const DurationChart = withRouter(_DurationChart);
+export const Subtitle = styled('span')`
   color: ${p => p.theme.gray300};
   font-size: ${p => p.theme.fontSizeMedium};
 `;
 
-const HighlightNumber = styled('div')<{color?: string}>`
+export const HighlightNumber = styled('div')<{color?: string}>`
   color: ${p => p.color};
   font-size: ${p => p.theme.fontSizeExtraLarge};
 `;

+ 130 - 0
static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidgetMetrics.tsx

@@ -0,0 +1,130 @@
+import {Fragment, useMemo} from 'react';
+
+import _EventsRequest from 'sentry/components/charts/eventsRequest';
+import {t} from 'sentry/locale';
+import MetricsRequest from 'sentry/utils/metrics/metricsRequest';
+import {decodeList} from 'sentry/utils/queryString';
+import useApi from 'sentry/utils/useApi';
+import _DurationChart from 'sentry/views/performance/charts/chart';
+
+import {GenericPerformanceWidget} from '../components/performanceWidget';
+import {transformMetricsToArea} from '../transforms/transformMetricsToArea';
+import {PerformanceWidgetProps, QueryDefinition, WidgetDataResult} from '../types';
+import {ChartDefinition, PerformanceWidgetSetting} from '../widgetDefinitions';
+
+import {DurationChart, HighlightNumber, Subtitle} from './singleFieldAreaWidget';
+
+type DataType = {
+  chart: WidgetDataResult & ReturnType<typeof transformMetricsToArea>;
+};
+
+export function SingleFieldAreaWidgetMetrics(
+  props: PerformanceWidgetProps & {
+    widgetDefinitions: Record<PerformanceWidgetSetting, ChartDefinition>;
+  }
+) {
+  const api = useApi();
+  const {
+    ContainerActions,
+    eventView,
+    organization,
+    chartSetting,
+    chartColor,
+    chartHeight,
+    fields,
+    widgetDefinitions,
+  } = props;
+
+  const globalSelection = eventView.getGlobalSelection();
+
+  if (fields.length !== 1) {
+    throw new Error(`Single field area can only accept a single field (${fields})`);
+  }
+
+  const field = fields[0];
+
+  // TODO(metrics): make this list complete once api is ready
+  const metricsFieldMap = {
+    [widgetDefinitions.p75_lcp_area.fields[0]]: widgetDefinitions.p75_lcp_area.fields[0],
+    [widgetDefinitions.tpm_area.fields[0]]: 'count(transaction.duration)',
+  };
+
+  const metricsField = metricsFieldMap[field];
+
+  if (!metricsField) {
+    throw new Error(`The field ${field} is not yet supported by metrics`);
+  }
+
+  const chart = useMemo<QueryDefinition<DataType, WidgetDataResult>>(
+    () => ({
+      fields: metricsField,
+      component: ({
+        start,
+        end,
+        period,
+        project,
+        environment,
+        children,
+        fields: chartFields,
+      }) => (
+        <MetricsRequest
+          api={api}
+          organization={organization}
+          start={start}
+          end={end}
+          statsPeriod={period}
+          project={project}
+          environment={environment}
+          query="transaction:foo" // TODO(metrics): make this dynamic once api is ready (widgetData.list.data[selectedListIndex].transaction)
+          field={decodeList(chartFields)}
+          includePrevious
+        >
+          {children}
+        </MetricsRequest>
+      ),
+      transform: transformMetricsToArea,
+    }),
+    [chartSetting]
+  );
+
+  const Queries = {chart};
+
+  return (
+    <GenericPerformanceWidget<DataType>
+      {...props}
+      fields={[metricsField]}
+      Subtitle={() => (
+        <Subtitle>
+          {globalSelection.datetime.period
+            ? t('Compared to last %s ', globalSelection.datetime.period)
+            : t('Compared to the last period')}
+        </Subtitle>
+      )}
+      HeaderActions={provided => (
+        <Fragment>
+          <HighlightNumber color={chartColor}>
+            {provided.widgetData.chart?.hasData
+              ? provided.widgetData.chart?.dataMean?.[0].label
+              : null}
+          </HighlightNumber>
+          <ContainerActions {...provided.widgetData.chart} />
+        </Fragment>
+      )}
+      Queries={Queries}
+      Visualizations={[
+        {
+          component: provided => (
+            <DurationChart
+              {...provided.widgetData.chart}
+              {...provided}
+              disableMultiAxis
+              disableXAxis
+              chartColors={chartColor ? [chartColor] : undefined}
+            />
+          ),
+          height: chartHeight,
+        },
+      ]}
+    />
+  );
+}

+ 14 - 4
static/app/views/projectDetail/charts/projectSessionsChartRequest.tsx

@@ -5,6 +5,7 @@ import omit from 'lodash/omit';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {Client} from 'sentry/api';
+import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
 import {getParams} from 'sentry/components/organizations/globalSelectionHeader/getParams';
 import {t} from 'sentry/locale';
 import {
@@ -28,7 +29,6 @@ import {Theme} from 'sentry/utils/theme';
 import {getCrashFreePercent} from 'sentry/views/releases/utils';
 
 import {DisplayModes} from '../projectCharts';
-import {shouldFetchPreviousPeriod} from '../utils';
 
 const omitIgnoredProps = (props: Props) =>
   omit(props, ['api', 'organization', 'children', 'selection.datetime.utc']);
@@ -88,10 +88,20 @@ class ProjectSessionsChartRequest extends React.Component<Props, State> {
   private unmounting: boolean = false;
 
   fetchData = async () => {
-    const {api, selection, onTotalValuesChange, displayMode, disablePrevious} =
-      this.props;
+    const {
+      api,
+      selection: {datetime},
+      onTotalValuesChange,
+      displayMode,
+      disablePrevious,
+    } = this.props;
     const shouldFetchWithPrevious =
-      !disablePrevious && shouldFetchPreviousPeriod(selection.datetime);
+      !disablePrevious &&
+      shouldFetchPreviousPeriod({
+        start: datetime.start,
+        end: datetime.end,
+        period: datetime.period,
+      });
 
     this.setState(state => ({
       reloading: state.timeseriesData !== null,

+ 8 - 2
static/app/views/projectDetail/projectScoreCards/projectApdexScoreCard.tsx

@@ -2,6 +2,7 @@ import * as React from 'react';
 import round from 'lodash/round';
 
 import AsyncComponent from 'sentry/components/asyncComponent';
+import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
 import Count from 'sentry/components/count';
 import {getParams} from 'sentry/components/organizations/globalSelectionHeader/getParams';
 import {parseStatsPeriod} from 'sentry/components/organizations/timeRangeSelector/utils';
@@ -16,7 +17,6 @@ import {getPeriod} from 'sentry/utils/getPeriod';
 import {getTermHelp, PERFORMANCE_TERM} from 'sentry/views/performance/data';
 
 import MissingPerformanceButtons from '../missingFeatureButtons/missingPerformanceButtons';
-import {shouldFetchPreviousPeriod} from '../utils';
 
 type Props = AsyncComponent['props'] & {
   organization: Organization;
@@ -66,7 +66,13 @@ class ProjectApdexScoreCard extends AsyncComponent<Props, State> {
       ],
     ];
 
-    if (shouldFetchPreviousPeriod(datetime)) {
+    if (
+      shouldFetchPreviousPeriod({
+        start: datetime.start,
+        end: datetime.end,
+        period: datetime.period,
+      })
+    ) {
       const {start: previousStart} = parseStatsPeriod(
         getPeriod({period, start: undefined, end: undefined}, {shouldDoublePeriod: true})
           .statsPeriod!

+ 11 - 3
static/app/views/projectDetail/projectScoreCards/projectStabilityScoreCard.tsx

@@ -2,7 +2,10 @@ import * as React from 'react';
 import round from 'lodash/round';
 
 import AsyncComponent from 'sentry/components/asyncComponent';
-import {getDiffInMinutes} from 'sentry/components/charts/utils';
+import {
+  getDiffInMinutes,
+  shouldFetchPreviousPeriod,
+} from 'sentry/components/charts/utils';
 import {getParams} from 'sentry/components/organizations/globalSelectionHeader/getParams';
 import ScoreCard from 'sentry/components/scoreCard';
 import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
@@ -19,7 +22,6 @@ import {
 } from 'sentry/views/releases/utils/sessionTerm';
 
 import MissingReleasesButtons from '../missingFeatureButtons/missingReleasesButtons';
-import {shouldFetchPreviousPeriod} from '../utils';
 
 type Props = AsyncComponent['props'] & {
   organization: Organization;
@@ -79,7 +81,13 @@ class ProjectStabilityScoreCard extends AsyncComponent<Props, State> {
       ],
     ];
 
-    if (shouldFetchPreviousPeriod(datetime)) {
+    if (
+      shouldFetchPreviousPeriod({
+        start: datetime.start,
+        end: datetime.end,
+        period: datetime.period,
+      })
+    ) {
       const doubledPeriod = getPeriod(
         {period, start: undefined, end: undefined},
         {shouldDoublePeriod: true}

+ 8 - 2
static/app/views/projectDetail/projectScoreCards/projectVelocityScoreCard.tsx

@@ -2,6 +2,7 @@ import * as React from 'react';
 
 import {fetchAnyReleaseExistence} from 'sentry/actionCreators/projects';
 import AsyncComponent from 'sentry/components/asyncComponent';
+import {shouldFetchPreviousPeriod} from 'sentry/components/charts/utils';
 import {getParams} from 'sentry/components/organizations/globalSelectionHeader/getParams';
 import {parseStatsPeriod} from 'sentry/components/organizations/timeRangeSelector/utils';
 import ScoreCard from 'sentry/components/scoreCard';
@@ -12,7 +13,6 @@ import {defined} from 'sentry/utils';
 import {getPeriod} from 'sentry/utils/getPeriod';
 
 import MissingReleasesButtons from '../missingFeatureButtons/missingReleasesButtons';
-import {shouldFetchPreviousPeriod} from '../utils';
 
 const API_LIMIT = 1000;
 
@@ -72,7 +72,13 @@ class ProjectVelocityScoreCard extends AsyncComponent<Props, State> {
       ],
     ];
 
-    if (shouldFetchPreviousPeriod(datetime)) {
+    if (
+      shouldFetchPreviousPeriod({
+        start: datetime.start,
+        end: datetime.end,
+        period: datetime.period,
+      })
+    ) {
       const {start: previousStart} = parseStatsPeriod(
         getPeriod({period, start: undefined, end: undefined}, {shouldDoublePeriod: true})
           .statsPeriod!

Some files were not shown because too many files changed in this diff