Browse Source

feat(dam): Expose derived metrics in dashboards (#32798)

Add support for derived metrics in Dashboards.

Co-authored-by: Shruthilaya <shruthilaya.jaganathan@sentry.io>
Matej Minar 2 years ago
parent
commit
75aed4dcc1

+ 5 - 1
static/app/components/createAlertButton.tsx

@@ -213,7 +213,11 @@ type CreateAlertFromViewButtonProps = ButtonProps & {
 
 function incompatibleYAxis(eventView: EventView): boolean {
   const column = explodeFieldString(eventView.getYAxis());
-  if (column.kind === 'field' || column.kind === 'equation') {
+  if (
+    column.kind === 'field' ||
+    column.kind === 'equation' ||
+    column.kind === 'calculatedField'
+  ) {
     return true;
   }
 

+ 2 - 1
static/app/components/dashboards/widgetQueriesForm.tsx

@@ -19,6 +19,7 @@ import {
   getAggregateAlias,
   getColumnsAndAggregatesAsStrings,
   isEquation,
+  stripDerivedMetricsPrefix,
   stripEquationPrefix,
 } from 'sentry/utils/discover/fields';
 import {Widget, WidgetQuery, WidgetType} from 'sentry/views/dashboardsV2/types';
@@ -41,7 +42,7 @@ export const generateOrderOptions = ({
   const isMetrics = widgetType === WidgetType.METRICS;
   const options: SelectValue<string>[] = [];
   let equations = 0;
-  (isMetrics ? aggregates : [...aggregates, ...columns])
+  (isMetrics ? aggregates.map(stripDerivedMetricsPrefix) : [...aggregates, ...columns])
     .filter(field => !!field)
     .forEach(field => {
       let alias = getAggregateAlias(field);

+ 11 - 3
static/app/components/dashboards/widgetQueryFields.tsx

@@ -112,7 +112,11 @@ function WidgetQueryFields({
   const filterPrimaryOptions = option => {
     if (widgetType === WidgetType.METRICS) {
       if (displayType === DisplayType.TABLE) {
-        return [FieldValueKind.FUNCTION, FieldValueKind.TAG].includes(option.value.kind);
+        return [
+          FieldValueKind.FUNCTION,
+          FieldValueKind.TAG,
+          FieldValueKind.NUMERIC_METRICS,
+        ].includes(option.value.kind);
       }
       if (displayType === DisplayType.TOP_N) {
         return option.value.kind === FieldValueKind.TAG;
@@ -132,11 +136,15 @@ function WidgetQueryFields({
       }
     }
 
-    return option.value.kind === FieldValueKind.FUNCTION;
+    return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
+      option.value.kind
+    );
   };
 
   const filterMetricsOptions = option => {
-    return option.value.kind === FieldValueKind.FUNCTION;
+    return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
+      option.value.kind
+    );
   };
 
   const filterAggregateParameters =

+ 1 - 1
static/app/types/metrics.tsx

@@ -1,4 +1,4 @@
-export type MetricsType = 'set' | 'counter' | 'distribution';
+export type MetricsType = 'set' | 'counter' | 'distribution' | 'numeric';
 
 export type MetricsOperation =
   | 'sum'

+ 44 - 4
static/app/utils/discover/fields.tsx

@@ -79,6 +79,11 @@ export type QueryFieldValue =
       kind: 'field';
       alias?: string;
     }
+  | {
+      field: string;
+      kind: 'calculatedField';
+      alias?: string;
+    }
   | {
       field: string;
       kind: 'equation';
@@ -857,6 +862,7 @@ export function parseArguments(functionText: string, columnText: string): string
 // `|` is an invalid field character, so it is used to determine whether a field is an equation or not
 const EQUATION_PREFIX = 'equation|';
 const EQUATION_ALIAS_PATTERN = /^equation\[(\d+)\]$/;
+export const CALCULATED_FIELD_PREFIX = 'calculated|';
 
 export function isEquation(field: string): boolean {
   return field.startsWith(EQUATION_PREFIX);
@@ -933,11 +939,23 @@ export function generateAggregateFields(
   return fields.map(field => ({field})) as Field[];
 }
 
+export function isDerivedMetric(field: string): boolean {
+  return field.startsWith(CALCULATED_FIELD_PREFIX);
+}
+
+export function stripDerivedMetricsPrefix(field: string): string {
+  return field.replace(CALCULATED_FIELD_PREFIX, '');
+}
+
 export function explodeFieldString(field: string, alias?: string): Column {
   if (isEquation(field)) {
     return {kind: 'equation', field: getEquation(field), alias};
   }
 
+  if (isDerivedMetric(field)) {
+    return {kind: 'calculatedField', field: stripDerivedMetricsPrefix(field)};
+  }
+
   const results = parseFunction(field);
 
   if (results) {
@@ -961,6 +979,10 @@ export function generateFieldAsString(value: QueryFieldValue): string {
     return value.field;
   }
 
+  if (value.kind === 'calculatedField') {
+    return `${CALCULATED_FIELD_PREFIX}${value.field}`;
+  }
+
   if (value.kind === 'equation') {
     return `${EQUATION_PREFIX}${value.field}`;
   }
@@ -1001,11 +1023,28 @@ export function isAggregateField(field: string): boolean {
 }
 
 export function isAggregateFieldOrEquation(field: string): boolean {
-  return isAggregateField(field) || isAggregateEquation(field);
+  return isAggregateField(field) || isAggregateEquation(field) || isNumericMetrics(field);
+}
+
+/**
+ * Temporary hardcoded hack to enable testing derived metrics.
+ * Can be removed after we get rid of getAggregateFields
+ */
+export function isNumericMetrics(field: string): boolean {
+  return [
+    'session.crash_free_rate',
+    'session.crashed',
+    'session.errored_preaggregated',
+    'session.errored_set',
+    'session.init',
+  ].includes(field);
 }
 
 export function getAggregateFields(fields: string[]): string[] {
-  return fields.filter(field => isAggregateField(field) || isAggregateEquation(field));
+  return fields.filter(
+    field =>
+      isAggregateField(field) || isAggregateEquation(field) || isNumericMetrics(field)
+  );
 }
 
 export function getColumnsAndAggregates(fields: string[]): {
@@ -1022,13 +1061,14 @@ export function getColumnsAndAggregatesAsStrings(fields: QueryFieldValue[]): {
   columns: string[];
   fieldAliases: string[];
 } {
+  // TODO(dam): distinguish between metrics, derived metrics and tags
   const aggregateFields: string[] = [];
   const nonAggregateFields: string[] = [];
   const fieldAliases: string[] = [];
 
   for (const field of fields) {
     const fieldString = generateFieldAsString(field);
-    if (field.kind === 'function') {
+    if (field.kind === 'function' || field.kind === 'calculatedField') {
       aggregateFields.push(fieldString);
     } else if (field.kind === 'equation') {
       if (isAggregateEquation(fieldString)) {
@@ -1190,7 +1230,7 @@ export function fieldAlignment(
 /**
  * Match on types that are legal to show on a timeseries chart.
  */
-export function isLegalYAxisType(match: ColumnType) {
+export function isLegalYAxisType(match: ColumnType | MetricsType) {
   return ['number', 'integer', 'duration', 'percentage'].includes(match);
 }
 

+ 2 - 0
static/app/utils/metrics/fields.tsx

@@ -6,6 +6,7 @@ export enum SessionMetric {
   SESSION = 'sentry.sessions.session',
   SESSION_DURATION = 'sentry.sessions.session.duration',
   SESSION_ERROR = 'sentry.sessions.session.error',
+  SESSION_CRASH_FREE_RATE = 'session.crash_free_rate',
   USER = 'sentry.sessions.user',
 }
 
@@ -41,6 +42,7 @@ export const METRIC_TO_COLUMN_TYPE: Readonly<
   [SessionMetric.SESSION_ERROR]: 'integer',
   [SessionMetric.SESSION_DURATION]: 'duration',
   [SessionMetric.SESSION]: 'integer',
+  [SessionMetric.SESSION_CRASH_FREE_RATE]: 'percentage',
 
   // Transaction metrics
   [TransactionMetric.USER]: 'integer',

+ 18 - 0
static/app/views/dashboardsV2/widgetBuilder/metricWidget/fields.tsx

@@ -12,6 +12,7 @@ export function generateMetricsWidgetFieldOptions(
   const fieldNames: string[] = [];
   const operations = new Set<MetricsOperation>();
   const knownOperations = Object.keys(METRICS_OPERATIONS);
+  const numericFields: MetricsMeta[] = [];
 
   // If there are no fields, we do not want to render aggregations, nor tags
   // Metrics API needs at least one field to be able to return data
@@ -24,6 +25,10 @@ export function generateMetricsWidgetFieldOptions(
     .forEach(field => {
       field.operations.forEach(operation => operations.add(operation));
       fieldNames.push(field.name);
+      if (field.type === 'numeric') {
+        numericFields.push(field);
+        return;
+      }
 
       fieldOptions[`field:${field.name}`] = {
         label: field.name,
@@ -65,6 +70,19 @@ export function generateMetricsWidgetFieldOptions(
       };
     });
 
+  numericFields.forEach(field => {
+    fieldOptions[`field:${field.name}`] = {
+      label: `${field.name}()`,
+      value: {
+        kind: FieldValueKind.NUMERIC_METRICS,
+        meta: {
+          name: field.name,
+          dataType: 'numeric',
+        },
+      },
+    };
+  });
+
   if (defined(tagKeys)) {
     tagKeys
       .sort((a, b) => a.localeCompare(b))

+ 2 - 1
static/app/views/dashboardsV2/widgetCard/chart.tsx

@@ -32,6 +32,7 @@ import {
   getMeasurementSlug,
   isEquation,
   maybeEquationAlias,
+  stripDerivedMetricsPrefix,
   stripEquationPrefix,
 } from 'sentry/utils/discover/fields';
 import getDynamicText from 'sentry/utils/getDynamicText';
@@ -128,7 +129,7 @@ class WidgetCardChart extends React.Component<WidgetCardChartProps, State> {
     }
 
     return tableResults.map((result, i) => {
-      const fields = widget.queries[i]?.fields ?? [];
+      const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
       const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
       const eventView = eventViewFromWidget(
         widget.title,

+ 4 - 2
static/app/views/dashboardsV2/widgetCard/metricsWidgetQueries.tsx

@@ -10,6 +10,7 @@ import {t} from 'sentry/locale';
 import {MetricsApiResponse, OrganizationSummary, PageFilters} from 'sentry/types';
 import {Series} from 'sentry/types/echarts';
 import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
+import {stripDerivedMetricsPrefix} from 'sentry/utils/discover/fields';
 import {TOP_N} from 'sentry/utils/discover/types';
 import {transformMetricsResponseToSeries} from 'sentry/utils/metrics/transformMetricsResponseToSeries';
 import {transformMetricsResponseToTable} from 'sentry/utils/metrics/transformMetricsResponseToTable';
@@ -163,15 +164,16 @@ class MetricsWidgetQueries extends React.Component<Props, State> {
     const interval = getWidgetInterval(widget, {start, end, period});
 
     const promises = widget.queries.map(query => {
+      const aggregates = query.aggregates.map(stripDerivedMetricsPrefix);
       const requestData = {
-        field: query.aggregates,
+        field: aggregates,
         orgSlug: organization.slug,
         end,
         environment: environments,
         groupBy: query.columns,
         interval,
         limit: this.limit,
-        orderBy: query.orderby || (this.limit ? query.aggregates[0] : undefined),
+        orderBy: query.orderby || (this.limit ? aggregates[0] : undefined),
         project: projects,
         query: query.conditions,
         start,

+ 19 - 8
static/app/views/eventsV2/table/queryField.tsx

@@ -167,13 +167,14 @@ class QueryField extends React.Component<Props> {
       case FieldValueKind.FIELD:
         fieldValue = {kind: 'field', field: value.meta.name};
         break;
+      case FieldValueKind.NUMERIC_METRICS:
+        fieldValue = {
+          kind: 'calculatedField',
+          field: value.meta.name,
+        };
+        break;
       case FieldValueKind.FUNCTION:
-        if (current.kind === 'field') {
-          fieldValue = {
-            kind: 'function',
-            function: [value.meta.name as AggregationKey, '', undefined, undefined],
-          };
-        } else if (current.kind === 'function') {
+        if (current.kind === 'function') {
           fieldValue = {
             kind: 'function',
             function: [
@@ -183,6 +184,11 @@ class QueryField extends React.Component<Props> {
               current.function[3],
             ],
           };
+        } else {
+          fieldValue = {
+            kind: 'function',
+            function: [value.meta.name as AggregationKey, '', undefined, undefined],
+          };
         }
         break;
       default:
@@ -332,7 +338,7 @@ class QueryField extends React.Component<Props> {
       }
     }
 
-    if (fieldValue?.kind === 'field') {
+    if (fieldValue?.kind === 'field' || fieldValue?.kind === 'calculatedField') {
       field = this.getFieldOrTagOrMeasurementValue(fieldValue.field);
       fieldOptions = this.appendFieldIfUnknown(fieldOptions, field);
     }
@@ -547,8 +553,13 @@ class QueryField extends React.Component<Props> {
         text = kind;
         tagType = 'warning';
         break;
+      case FieldValueKind.NUMERIC_METRICS:
+        text = 'f(x)';
+        tagType = 'success';
+        break;
       case FieldValueKind.FIELD:
-        text = DEPRECATED_FIELDS.includes(label) ? 'deprecated' : kind;
+      case FieldValueKind.METRICS:
+        text = DEPRECATED_FIELDS.includes(label) ? 'deprecated' : 'field';
         tagType = 'highlight';
         break;
       default:

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