Browse Source

feat(ddm): ddm dashboard widget type (#60080)

Ogi 1 year ago
parent
commit
42c2947121

+ 71 - 3
static/app/actionCreators/metrics.tsx

@@ -3,8 +3,9 @@ import {getInterval} from 'sentry/components/charts/utils';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {DateString, MetricsApiResponse, Organization} from 'sentry/types';
 import {defined} from 'sentry/utils';
+import {getDateTimeParams, getMetricsInterval, parseMRI} from 'sentry/utils/metrics';
 
-export type DoMetricsRequestOptions = {
+export type DoReleaseHealthRequestOptions = {
   field: string[];
   orgSlug: Organization['slug'];
   cursor?: string;
@@ -25,7 +26,7 @@ export type DoMetricsRequestOptions = {
   statsPeriodStart?: string;
 };
 
-export const doMetricsRequest = (
+export const doReleaseHealthRequest = (
   api: Client,
   {
     field,
@@ -44,7 +45,7 @@ export const doMetricsRequest = (
     statsPeriodStart,
     statsPeriodEnd,
     ...dateTime
-  }: DoMetricsRequestOptions
+  }: DoReleaseHealthRequestOptions
 ): Promise<MetricsApiResponse | [MetricsApiResponse, string, ResponseMeta]> => {
   const {start, end, statsPeriod} = normalizeDateTimeParams(dateTime, {
     allowEmptyPeriod: true,
@@ -75,3 +76,70 @@ export const doMetricsRequest = (
 
   return api.requestPromise(pathname, {includeAllArgs, query: urlQuery});
 };
+
+export type DoMetricsRequestOptions = {
+  field: string[];
+  orgSlug: Organization['slug'];
+  cursor?: string;
+  end?: DateString;
+  environment?: Readonly<string[]>;
+  groupBy?: string[];
+  includeAllArgs?: boolean;
+  includeSeries?: number;
+  includeTotals?: number;
+  interval?: string;
+  limit?: number;
+  orderBy?: string;
+  project?: Readonly<number[]>;
+  query?: string;
+  start?: DateString;
+  statsPeriod?: string | null;
+  statsPeriodEnd?: string;
+  statsPeriodStart?: string;
+};
+
+export const doMetricsRequest = (
+  api: Client,
+  {
+    field,
+    orgSlug,
+    environment,
+    groupBy,
+    interval,
+    limit,
+    project,
+    query,
+    includeAllArgs = false,
+    ...dateTime
+  }: DoReleaseHealthRequestOptions
+): Promise<MetricsApiResponse | [MetricsApiResponse, string, ResponseMeta]> => {
+  const {useCase} = parseMRI(field[0]) ?? {useCase: 'custom'};
+
+  const {start, end, statsPeriod, utc} = normalizeDateTimeParams(dateTime, {
+    allowEmptyPeriod: true,
+    allowAbsoluteDatetime: true,
+    allowAbsolutePageDatetime: true,
+  });
+
+  // @ts-expect-error
+  const datetime = getDateTimeParams({start, end, period: statsPeriod, utc});
+
+  const metricsInterval = getMetricsInterval(datetime, useCase);
+
+  const urlQuery = {
+    ...datetime,
+    query,
+    project,
+    environment,
+    field,
+    useCase,
+    interval: interval || metricsInterval,
+    groupBy,
+    per_page: limit,
+    useNewMetricsLayer: false,
+  };
+
+  const pathname = `/organizations/${orgSlug}/metrics/data/`;
+
+  return api.requestPromise(pathname, {includeAllArgs, query: urlQuery});
+};

+ 27 - 0
static/app/components/modals/widgetViewerModal.tsx

@@ -84,6 +84,7 @@ import {
 } from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
 import {GenericWidgetQueriesChildrenProps} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries';
 import IssueWidgetQueries from 'sentry/views/dashboards/widgetCard/issueWidgetQueries';
+import MetricWidgetQueries from 'sentry/views/dashboards/widgetCard/metricWidgetQueries';
 import ReleaseWidgetQueries from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries';
 import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer';
 import WidgetQueries from 'sentry/views/dashboards/widgetCard/widgetQueries';
@@ -790,6 +791,32 @@ function WidgetViewerModal(props: Props) {
             {renderReleaseTable}
           </ReleaseWidgetQueries>
         );
+      case WidgetType.METRICS:
+        if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
+          return renderReleaseTable({
+            tableResults: tableData,
+            loading: false,
+            pageLinks: defaultPageLinks,
+          });
+        }
+        return (
+          <MetricWidgetQueries
+            api={api}
+            organization={organization}
+            widget={tableWidget}
+            selection={modalTableSelection}
+            limit={
+              widget.displayType === DisplayType.TABLE
+                ? FULL_TABLE_ITEM_LIMIT
+                : HALF_TABLE_ITEM_LIMIT
+            }
+            cursor={cursor}
+            dashboardFilters={dashboardFilters}
+          >
+            {/* TODO(ddm): Check if we need to use a diffrent implementation, for now we fallback to release table */}
+            {renderReleaseTable}
+          </MetricWidgetQueries>
+        );
       case WidgetType.DISCOVER:
       default:
         if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {

+ 3 - 4
static/app/utils/metrics.tsx

@@ -139,7 +139,7 @@ export function useMetricsData({
   const useCase = getUseCaseFromMRI(mri);
   const field = op ? `${op}(${mri})` : mri;
 
-  const interval = getMetricsInterval(datetime, mri);
+  const interval = getMetricsInterval(datetime, useCase);
 
   const queryToSend = {
     ...getDateTimeParams(datetime),
@@ -240,7 +240,7 @@ export function useMetricsDataZoom(props: MetricsQuery) {
 }
 
 // Wraps getInterval since other users of this function, and other metric use cases do not have support for 10s granularity
-function getMetricsInterval(dateTimeObj: DateTimeObject, mri: string) {
+export function getMetricsInterval(dateTimeObj: DateTimeObject, useCase: UseCase) {
   const interval = getInterval(dateTimeObj, 'metrics');
 
   if (interval !== '1m') {
@@ -248,7 +248,6 @@ function getMetricsInterval(dateTimeObj: DateTimeObject, mri: string) {
   }
 
   const diffInMinutes = getDiffInMinutes(dateTimeObj);
-  const useCase = getUseCaseFromMRI(mri);
 
   if (diffInMinutes <= 60 && useCase === 'custom') {
     return '10s';
@@ -257,7 +256,7 @@ function getMetricsInterval(dateTimeObj: DateTimeObject, mri: string) {
   return interval;
 }
 
-function getDateTimeParams({start, end, period}: PageFilters['datetime']) {
+export function getDateTimeParams({start, end, period}: PageFilters['datetime']) {
   return period
     ? {statsPeriod: period}
     : {start: moment(start).toISOString(), end: moment(end).toISOString()};

+ 10 - 1
static/app/views/dashboards/datasetConfig/base.tsx

@@ -23,6 +23,7 @@ import {getNumEquations} from '../utils';
 
 import {ErrorsAndTransactionsConfig} from './errorsAndTransactions';
 import {IssuesConfig} from './issues';
+import {MetricsConfig} from './metrics';
 import {ReleasesConfig} from './releases';
 
 export type WidgetBuilderSearchBarProps = {
@@ -212,16 +213,24 @@ export function getDatasetConfig<T extends WidgetType | undefined>(
   ? typeof IssuesConfig
   : T extends WidgetType.RELEASE
   ? typeof ReleasesConfig
+  : T extends WidgetType.METRICS
+  ? typeof MetricsConfig
   : typeof ErrorsAndTransactionsConfig;
 
 export function getDatasetConfig(
   widgetType?: WidgetType
-): typeof IssuesConfig | typeof ReleasesConfig | typeof ErrorsAndTransactionsConfig {
+):
+  | typeof IssuesConfig
+  | typeof ReleasesConfig
+  | typeof MetricsConfig
+  | typeof ErrorsAndTransactionsConfig {
   switch (widgetType) {
     case WidgetType.ISSUE:
       return IssuesConfig;
     case WidgetType.RELEASE:
       return ReleasesConfig;
+    case WidgetType.METRICS:
+      return MetricsConfig;
     case WidgetType.DISCOVER:
     default:
       return ErrorsAndTransactionsConfig;

+ 244 - 0
static/app/views/dashboards/datasetConfig/metrics.tsx

@@ -0,0 +1,244 @@
+import omit from 'lodash/omit';
+
+import {doMetricsRequest} from 'sentry/actionCreators/metrics';
+import {Client, ResponseMeta} from 'sentry/api';
+import {t} from 'sentry/locale';
+import {
+  MetricsApiResponse,
+  Organization,
+  PageFilters,
+  SessionApiResponse,
+  SessionField,
+} from 'sentry/types';
+import {Series} from 'sentry/types/echarts';
+import {TableData} from 'sentry/utils/discover/discoverQuery';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
+import {ReleaseSearchBar} from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar';
+
+import {DisplayType, Widget, WidgetQuery} from '../types';
+import {getWidgetInterval} from '../utils';
+import {resolveDerivedStatusFields} from '../widgetCard/metricWidgetQueries';
+import {getSeriesName} from '../widgetCard/transformSessionsResponseToSeries';
+import {
+  changeObjectValuesToTypes,
+  getDerivedMetrics,
+  mapDerivedMetricsToFields,
+} from '../widgetCard/transformSessionsResponseToTable';
+
+import {DatasetConfig, handleOrderByReset} from './base';
+
+const DEFAULT_WIDGET_QUERY: WidgetQuery = {
+  name: '',
+  fields: [`avg(duration)`],
+  columns: [],
+  fieldAliases: [],
+  aggregates: [`avg(duration)`],
+  conditions: '',
+  orderby: `-avg(duration)`,
+};
+
+export const MetricsConfig: DatasetConfig<MetricsApiResponse, MetricsApiResponse> = {
+  defaultWidgetQuery: DEFAULT_WIDGET_QUERY,
+  enableEquations: false,
+  getTableRequest: (
+    api: Client,
+    _: Widget,
+    query: WidgetQuery,
+    organization: Organization,
+    pageFilters: PageFilters,
+    __?: OnDemandControlContext,
+    limit?: number,
+    cursor?: string
+  ) =>
+    getMetricRequest(
+      0,
+      1,
+      api,
+      query,
+      organization,
+      pageFilters,
+      undefined,
+      limit,
+      cursor
+    ),
+  getSeriesRequest: getMetricSeriesRequest,
+  getCustomFieldRenderer: (field, meta) => getFieldRenderer(field, meta, false),
+  // TODO(ddm): check if we need a MetricSearchBar
+  SearchBar: ReleaseSearchBar,
+  handleColumnFieldChangeOverride,
+  handleOrderByReset: handleMetricTableOrderByReset,
+  supportedDisplayTypes: [
+    DisplayType.AREA,
+    DisplayType.BAR,
+    DisplayType.BIG_NUMBER,
+    DisplayType.LINE,
+    DisplayType.TABLE,
+    DisplayType.TOP_N,
+  ],
+  transformSeries: transformSessionsResponseToSeries,
+  transformTable: transformSessionsResponseToTable,
+  getTableFieldOptions: () => ({}),
+};
+
+function getMetricSeriesRequest(
+  api: Client,
+  widget: Widget,
+  queryIndex: number,
+  organization: Organization,
+  pageFilters: PageFilters
+) {
+  const query = widget.queries[queryIndex];
+  const {displayType, limit} = widget;
+
+  const {datetime} = pageFilters;
+  const {start, end, period} = datetime;
+
+  const includeTotals = query.columns.length > 0 ? 1 : 0;
+  const interval = getWidgetInterval(
+    displayType,
+    {start, end, period},
+    '5m'
+    // requesting low fidelity for release sort because metrics api can't return 100 rows of high fidelity series data
+  );
+
+  return getMetricRequest(
+    1,
+    includeTotals,
+    api,
+    query,
+    organization,
+    pageFilters,
+    interval,
+    limit
+  );
+}
+
+function handleMetricTableOrderByReset(widgetQuery: WidgetQuery, newFields: string[]) {
+  const disableSortBy = widgetQuery.columns.includes('session.status');
+  if (disableSortBy) {
+    widgetQuery.orderby = '';
+  }
+  return handleOrderByReset(widgetQuery, newFields);
+}
+
+function handleColumnFieldChangeOverride(widgetQuery: WidgetQuery): WidgetQuery {
+  if (widgetQuery.aggregates.length === 0) {
+    // Release Health widgets require an aggregate in tables
+    const defaultReleaseHealthAggregate = `crash_free_rate(${SessionField.SESSION})`;
+    widgetQuery.aggregates = [defaultReleaseHealthAggregate];
+    widgetQuery.fields = widgetQuery.fields
+      ? [...widgetQuery.fields, defaultReleaseHealthAggregate]
+      : [defaultReleaseHealthAggregate];
+  }
+  return widgetQuery;
+}
+
+export function transformSessionsResponseToTable(
+  data: SessionApiResponse | MetricsApiResponse,
+  widgetQuery: WidgetQuery
+): TableData {
+  const {derivedStatusFields, injectedFields} = resolveDerivedStatusFields(
+    widgetQuery.aggregates
+  );
+  const rows = data.groups.map((group, index) => ({
+    id: String(index),
+    ...mapDerivedMetricsToFields(group.by),
+    // if `sum(session)` or `count_unique(user)` are not
+    // requested as a part of the payload for
+    // derived status metrics through the Sessions API,
+    // they are injected into the payload and need to be
+    // stripped.
+    ...omit(mapDerivedMetricsToFields(group.totals), injectedFields),
+    // if session.status is a groupby, some post processing
+    // is needed to calculate the status derived metrics
+    // from grouped results of `sum(session)` or `count_unique(user)`
+    ...getDerivedMetrics(group.by, group.totals, derivedStatusFields),
+  }));
+
+  const singleRow = rows[0];
+  const meta = {
+    ...changeObjectValuesToTypes(omit(singleRow, 'id')),
+  };
+  return {meta, data: rows};
+}
+
+export function transformSessionsResponseToSeries(
+  data: SessionApiResponse | MetricsApiResponse,
+  widgetQuery: WidgetQuery
+) {
+  if (data === null) {
+    return [];
+  }
+
+  const queryAlias = widgetQuery.name;
+
+  const results: Series[] = [];
+
+  if (!data.groups.length) {
+    return [
+      {
+        seriesName: `(${t('no results')})`,
+        data: data.intervals.map(interval => ({
+          name: interval,
+          value: 0,
+        })),
+      },
+    ];
+  }
+
+  data.groups.forEach(group => {
+    Object.keys(group.series).forEach(field => {
+      results.push({
+        seriesName: getSeriesName(field, group, queryAlias),
+        data: data.intervals.map((interval, index) => ({
+          name: interval,
+          value: group.series[field][index] ?? 0,
+        })),
+      });
+    });
+  });
+
+  return results;
+}
+
+function getMetricRequest(
+  includeSeries: number,
+  includeTotals: number,
+  api: Client,
+  query: WidgetQuery,
+  organization: Organization,
+  pageFilters: PageFilters,
+  interval?: string,
+  limit?: number,
+  cursor?: string
+) {
+  const {environments, projects, datetime} = pageFilters;
+  const {start, end, period} = datetime;
+
+  const columns = query.columns;
+
+  const requestData = {
+    field: query.aggregates,
+    orgSlug: organization.slug,
+    end,
+    environment: environments,
+    groupBy: columns,
+    limit: columns.length === 0 ? 1 : limit,
+    orderBy: '',
+    interval,
+    project: projects,
+    query: query.conditions,
+    start,
+    statsPeriod: period,
+    includeAllArgs: true,
+    cursor,
+    includeSeries,
+    includeTotals,
+  };
+
+  // TODO(ddm): get rid of this cast
+  return doMetricsRequest(api, requestData) as Promise<
+    [MetricsApiResponse, string | undefined, ResponseMeta | undefined]
+  >;
+}

+ 2 - 2
static/app/views/dashboards/datasetConfig/releases.tsx

@@ -1,7 +1,7 @@
 import omit from 'lodash/omit';
 import trimStart from 'lodash/trimStart';
 
-import {doMetricsRequest} from 'sentry/actionCreators/metrics';
+import {doReleaseHealthRequest} from 'sentry/actionCreators/metrics';
 import {doSessionsRequest} from 'sentry/actionCreators/sessions';
 import {Client} from 'sentry/api';
 import {t} from 'sentry/locale';
@@ -509,7 +509,7 @@ function getReleasesRequest(
       includeSeries,
       includeTotals,
     };
-    requester = doMetricsRequest;
+    requester = doReleaseHealthRequest;
 
     if (
       rawOrderby &&

+ 2 - 1
static/app/views/dashboards/types.tsx

@@ -24,7 +24,8 @@ export enum DisplayType {
 export enum WidgetType {
   DISCOVER = 'discover',
   ISSUE = 'issue',
-  RELEASE = 'metrics', // TODO(dashboards): Rename this on backend and then change here
+  RELEASE = 'metrics', // TODO(ddm): rename RELEASE to 'release', and METRICS to 'metrics'
+  METRICS = 'custom-metrics',
 }
 
 export type WidgetQuery = {

+ 19 - 12
static/app/views/dashboards/widgetBuilder/buildSteps/dataSetStep.tsx

@@ -10,14 +10,15 @@ import {DataSet} from '../utils';
 
 import {BuildStep} from './buildStep';
 
-const DATASET_CHOICES: [DataSet, string][] = [
-  [DataSet.EVENTS, t('Errors and Transactions')],
-  [DataSet.ISSUES, t('Issues (States, Assignment, Time, etc.)')],
-];
+// const DATASET_CHOICES: [DataSet, string][] = [
+//   [DataSet.EVENTS, t('Errors and Transactions')],
+//   [DataSet.ISSUES, t('Issues (States, Assignment, Time, etc.)')],
+// ];
 
 interface Props {
   dataSet: DataSet;
   displayType: DisplayType;
+  hasCustomMetricsFeature: boolean;
   hasReleaseHealthFeature: boolean;
   onChange: (dataSet: DataSet) => void;
 }
@@ -26,6 +27,7 @@ export function DataSetStep({
   dataSet,
   onChange,
   hasReleaseHealthFeature,
+  hasCustomMetricsFeature,
   displayType,
 }: Props) {
   const disabledChoices: RadioGroupProps<string>['disabledChoices'] = [];
@@ -37,6 +39,18 @@ export function DataSetStep({
     ]);
   }
 
+  const datasetChoices = new Map<string, string>();
+  datasetChoices.set(DataSet.EVENTS, t('Errors and Transactions'));
+  datasetChoices.set(DataSet.ISSUES, t('Issues (States, Assignment, Time, etc.)'));
+
+  if (hasReleaseHealthFeature) {
+    datasetChoices.set(DataSet.RELEASES, t('Releases (Sessions, Crash rates)'));
+  }
+
+  if (hasCustomMetricsFeature) {
+    datasetChoices.set(DataSet.METRICS, t('Custom Metrics'));
+  }
+
   return (
     <BuildStep
       title={t('Choose your dataset')}
@@ -52,14 +66,7 @@ export function DataSetStep({
       <DataSetChoices
         label="dataSet"
         value={dataSet}
-        choices={
-          hasReleaseHealthFeature
-            ? [
-                ...DATASET_CHOICES,
-                [DataSet.RELEASES, t('Releases (Sessions, Crash rates)')],
-              ]
-            : DATASET_CHOICES
-        }
+        choices={[...datasetChoices.entries()]}
         disabledChoices={disabledChoices}
         onChange={newDataSet => {
           onChange(newDataSet as DataSet);

+ 1 - 0
static/app/views/dashboards/widgetBuilder/utils.tsx

@@ -42,6 +42,7 @@ export enum DataSet {
   EVENTS = 'events',
   ISSUES = 'issues',
   RELEASES = 'releases',
+  METRICS = 'metrics',
 }
 
 export enum SortDirection {

+ 5 - 6
static/app/views/dashboards/widgetBuilder/widgetBuilder.tsx

@@ -95,12 +95,14 @@ const WIDGET_TYPE_TO_DATA_SET = {
   [WidgetType.DISCOVER]: DataSet.EVENTS,
   [WidgetType.ISSUE]: DataSet.ISSUES,
   [WidgetType.RELEASE]: DataSet.RELEASES,
+  [WidgetType.METRICS]: DataSet.METRICS,
 };
 
 export const DATA_SET_TO_WIDGET_TYPE = {
   [DataSet.EVENTS]: WidgetType.DISCOVER,
   [DataSet.ISSUES]: WidgetType.ISSUE,
   [DataSet.RELEASES]: WidgetType.RELEASE,
+  [DataSet.METRICS]: WidgetType.METRICS,
 };
 
 interface RouteParams {
@@ -176,6 +178,7 @@ function WidgetBuilder({
   }
 
   const hasReleaseHealthFeature = organization.features.includes('dashboards-rh-widget');
+  const hasCustomMetricsFeature = organization.features.includes('ddm-experimental');
 
   const filteredDashboardWidgets = dashboard.widgets.filter(({widgetType}) => {
     if (widgetType === WidgetType.RELEASE) {
@@ -355,12 +358,7 @@ function WidgetBuilder({
     router.setRouteLeaveHook(route, onUnload);
   }, [isSubmitting, state.userHasModified, route, router]);
 
-  const widgetType =
-    state.dataSet === DataSet.EVENTS
-      ? WidgetType.DISCOVER
-      : state.dataSet === DataSet.ISSUES
-      ? WidgetType.ISSUE
-      : WidgetType.RELEASE;
+  const widgetType = DATA_SET_TO_WIDGET_TYPE[state.dataSet];
 
   const currentWidget = {
     title: state.title,
@@ -1121,6 +1119,7 @@ function WidgetBuilder({
                                 displayType={state.displayType}
                                 onChange={handleDataSetChange}
                                 hasReleaseHealthFeature={hasReleaseHealthFeature}
+                                hasCustomMetricsFeature={hasCustomMetricsFeature}
                               />
                               {isTabularChart && (
                                 <DashboardsMEPConsumer>

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