Browse Source

feat(metrics-extraction): Allow span based metrics in alerts (#74334)

Allow creating alerts using span based metrics.
Hidden behind the `custom-metrics-extraction-rule` and
`custom-metrics-extraction-rule-ui` feature flags.

Closes https://github.com/getsentry/sentry/issues/73924
ArthurKnaus 8 months ago
parent
commit
364d31fcfe

+ 2 - 6
static/app/components/metrics/customMetricsEventData.tsx

@@ -22,14 +22,10 @@ import type {
   MRI,
   MRI,
 } from 'sentry/types/metrics';
 } from 'sentry/types/metrics';
 import {defined} from 'sentry/utils';
 import {defined} from 'sentry/utils';
-import {
+import {getDefaultAggregation, getMetricsUrl} from 'sentry/utils/metrics';
-  getDefaultAggregation,
-  getMetricsUrl,
-  isExtractedCustomMetric,
-} from 'sentry/utils/metrics';
 import {hasCustomMetrics} from 'sentry/utils/metrics/features';
 import {hasCustomMetrics} from 'sentry/utils/metrics/features';
 import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
 import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
-import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
+import {formatMRI, isExtractedCustomMetric, parseMRI} from 'sentry/utils/metrics/mri';
 import {MetricDisplayType} from 'sentry/utils/metrics/types';
 import {MetricDisplayType} from 'sentry/utils/metrics/types';
 import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
 import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
 import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
 import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';

+ 1 - 2
static/app/components/metrics/mriSelect/index.tsx

@@ -10,7 +10,6 @@ import type {MetricMeta, MRI} from 'sentry/types/metrics';
 import {type Fuse, useFuzzySearch} from 'sentry/utils/fuzzySearch';
 import {type Fuse, useFuzzySearch} from 'sentry/utils/fuzzySearch';
 import {
 import {
   isCustomMetric,
   isCustomMetric,
-  isExtractedCustomMetric,
   isSpanDuration,
   isSpanDuration,
   isSpanMeasurement,
   isSpanMeasurement,
   isTransactionDuration,
   isTransactionDuration,
@@ -18,7 +17,7 @@ import {
 } from 'sentry/utils/metrics';
 } from 'sentry/utils/metrics';
 import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
 import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
 import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
 import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
-import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
+import {formatMRI, isExtractedCustomMetric, parseMRI} from 'sentry/utils/metrics/mri';
 import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
 import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
 import useKeyPress from 'sentry/utils/useKeyPress';
 import useKeyPress from 'sentry/utils/useKeyPress';
 import useOrganization from 'sentry/utils/useOrganization';
 import useOrganization from 'sentry/utils/useOrganization';

+ 14 - 0
static/app/utils/metrics/extractionRules.tsx

@@ -0,0 +1,14 @@
+import type {MetricAggregation, MetricType} from 'sentry/types/metrics';
+
+export const aggregationToMetricType: Record<MetricAggregation, MetricType> = {
+  count: 'c',
+  count_unique: 's',
+  min: 'g',
+  max: 'g',
+  sum: 'g',
+  avg: 'g',
+  p50: 'd',
+  p75: 'd',
+  p95: 'd',
+  p99: 'd',
+};

+ 0 - 17
static/app/utils/metrics/index.spec.tsx

@@ -7,7 +7,6 @@ import {
   getDefaultAggregation,
   getDefaultAggregation,
   getFormattedMQL,
   getFormattedMQL,
   getMetricsInterval,
   getMetricsInterval,
-  isExtractedCustomMetric,
   isFormattedMQL,
   isFormattedMQL,
 } from 'sentry/utils/metrics';
 } from 'sentry/utils/metrics';
 import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
 import {DEFAULT_AGGREGATES} from 'sentry/utils/metrics/constants';
@@ -90,22 +89,6 @@ describe('getFormattedMQL', () => {
   });
   });
 });
 });
 
 
-describe('isExtractedCustomMetric', () => {
-  it('should return true if the metric name is prefixed', () => {
-    expect(isExtractedCustomMetric({mri: 'c:custom/span_attribute_123@none'})).toBe(true);
-    expect(isExtractedCustomMetric({mri: 's:custom/span_attribute_foo@none'})).toBe(true);
-    expect(isExtractedCustomMetric({mri: 'd:custom/span_attribute_bar@none'})).toBe(true);
-    expect(isExtractedCustomMetric({mri: 'g:custom/span_attribute_baz@none'})).toBe(true);
-  });
-
-  it('should return false if the metric name is not prefixed', () => {
-    expect(isExtractedCustomMetric({mri: 'c:custom/12span_attribute_@none'})).toBe(false);
-    expect(isExtractedCustomMetric({mri: 's:custom/foo@none'})).toBe(false);
-    expect(isExtractedCustomMetric({mri: 'd:custom/_span_attribute_@none'})).toBe(false);
-    expect(isExtractedCustomMetric({mri: 'g:custom/span_attributebaz@none'})).toBe(false);
-  });
-});
-
 describe('isFormattedMQL', () => {
 describe('isFormattedMQL', () => {
   it('should return true for a valid MQL string - simple', () => {
   it('should return true for a valid MQL string - simple', () => {
     const result = isFormattedMQL('avg(sentry.process_profile.symbolicate.process)');
     const result = isFormattedMQL('avg(sentry.process_profile.symbolicate.process)');

+ 4 - 5
static/app/utils/metrics/index.tsx

@@ -332,15 +332,14 @@ export function isCustomMetric({mri}: {mri: MRI}) {
   return mri.includes(':custom/');
   return mri.includes(':custom/');
 }
 }
 
 
-export function isExtractedCustomMetric({mri}: {mri: MRI}) {
-  // Extraced metrics are prefixed with `span_attribute_`
-  return mri.substring(1).startsWith(':custom/span_attribute_');
-}
-
 export function isVirtualMetric({mri}: {mri: MRI}) {
 export function isVirtualMetric({mri}: {mri: MRI}) {
   return mri.startsWith('v:');
   return mri.startsWith('v:');
 }
 }
 
 
+export function isCounterMetric({mri}: {mri: MRI}) {
+  return mri.startsWith('c:');
+}
+
 export function isSpanDuration({mri}: {mri: MRI}) {
 export function isSpanDuration({mri}: {mri: MRI}) {
   return mri === 'd:spans/duration@millisecond';
   return mri === 'd:spans/duration@millisecond';
 }
 }

+ 16 - 0
static/app/utils/metrics/mri.spec.tsx

@@ -2,6 +2,7 @@ import type {MetricType, MRI, ParsedMRI, UseCase} from 'sentry/types/metrics';
 import {
 import {
   formatMRI,
   formatMRI,
   getUseCaseFromMRI,
   getUseCaseFromMRI,
+  isExtractedCustomMetric,
   parseField,
   parseField,
   parseMRI,
   parseMRI,
   toMRI,
   toMRI,
@@ -223,3 +224,18 @@ describe('formatMRI', () => {
     expect(formatMRI('v:custom/bar|456@ms')).toEqual('bar');
     expect(formatMRI('v:custom/bar|456@ms')).toEqual('bar');
   });
   });
 });
 });
+
+describe('isExtractedCustomMetric', () => {
+  it('should return true if the metric name is prefixed', () => {
+    expect(isExtractedCustomMetric({mri: 'c:custom/span_attribute_123@none'})).toBe(true);
+    expect(isExtractedCustomMetric({mri: 's:custom/span_attribute_foo@none'})).toBe(true);
+    expect(isExtractedCustomMetric({mri: 'g:custom/span_attribute_baz@none'})).toBe(true);
+  });
+
+  it('should return false if the metric name is not prefixed', () => {
+    expect(isExtractedCustomMetric({mri: 'c:custom/12span_attribute_@none'})).toBe(false);
+    expect(isExtractedCustomMetric({mri: 's:custom/foo@none'})).toBe(false);
+    expect(isExtractedCustomMetric({mri: 'd:custom/_span_attribute_@none'})).toBe(false);
+    expect(isExtractedCustomMetric({mri: 'g:custom/span_attributebaz@none'})).toBe(false);
+  });
+});

+ 11 - 1
static/app/utils/metrics/mri.tsx

@@ -9,8 +9,10 @@ import type {
 import {parseFunction} from 'sentry/utils/discover/fields';
 import {parseFunction} from 'sentry/utils/discover/fields';
 
 
 export const DEFAULT_MRI: MRI = 'c:custom/sentry_metric@none';
 export const DEFAULT_MRI: MRI = 'c:custom/sentry_metric@none';
+export const DEFAULT_SPAN_MRI: MRI = 'c:custom/span_attribute_0@none';
 // This is a workaround as the alert builder requires a valid aggregate to be set
 // This is a workaround as the alert builder requires a valid aggregate to be set
 export const DEFAULT_METRIC_ALERT_FIELD = `sum(${DEFAULT_MRI})`;
 export const DEFAULT_METRIC_ALERT_FIELD = `sum(${DEFAULT_MRI})`;
+export const DEFAULT_SPAN_METRIC_ALERT_FIELD = `sum(${DEFAULT_SPAN_MRI})`;
 
 
 export function isMRI(mri?: unknown): mri is MRI {
 export function isMRI(mri?: unknown): mri is MRI {
   if (typeof mri !== 'string') {
   if (typeof mri !== 'string') {
@@ -123,7 +125,10 @@ export function getMRI(field: string): MRI {
 }
 }
 
 
 export function formatMRIField(aggregate: string) {
 export function formatMRIField(aggregate: string) {
-  if (aggregate === DEFAULT_METRIC_ALERT_FIELD) {
+  if (
+    aggregate === DEFAULT_METRIC_ALERT_FIELD ||
+    aggregate === DEFAULT_SPAN_METRIC_ALERT_FIELD
+  ) {
     return t('Select a metric to get started');
     return t('Select a metric to get started');
   }
   }
 
 
@@ -136,3 +141,8 @@ export function formatMRIField(aggregate: string) {
 
 
   return `${parsed.aggregation}(${formatMRI(parsed.mri)})`;
   return `${parsed.aggregation}(${formatMRI(parsed.mri)})`;
 }
 }
+
+export function isExtractedCustomMetric({mri}: {mri: MRI}) {
+  // Extraced metrics are prefixed with `span_attribute_`
+  return mri.substring(1).startsWith(':custom/span_attribute_');
+}

+ 5 - 2
static/app/utils/metrics/useMetricsMeta.tsx

@@ -1,8 +1,11 @@
 import {useMemo} from 'react';
 import {useMemo} from 'react';
 
 
 import type {PageFilters} from 'sentry/types/core';
 import type {PageFilters} from 'sentry/types/core';
-import {isExtractedCustomMetric} from 'sentry/utils/metrics';
+import {
-import {formatMRI, getUseCaseFromMRI} from 'sentry/utils/metrics/mri';
+  formatMRI,
+  getUseCaseFromMRI,
+  isExtractedCustomMetric,
+} from 'sentry/utils/metrics/mri';
 import {useVirtualMetricsContext} from 'sentry/utils/metrics/virtualMetricsContext';
 import {useVirtualMetricsContext} from 'sentry/utils/metrics/virtualMetricsContext';
 import type {ApiQueryKey} from 'sentry/utils/queryClient';
 import type {ApiQueryKey} from 'sentry/utils/queryClient';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useApiQuery} from 'sentry/utils/queryClient';

+ 1 - 14
static/app/utils/metrics/virtualMetricsContext.tsx

@@ -5,9 +5,9 @@ import type {
   MetricMeta,
   MetricMeta,
   MetricsExtractionCondition,
   MetricsExtractionCondition,
   MetricsExtractionRule,
   MetricsExtractionRule,
-  MetricType,
   MRI,
   MRI,
 } from 'sentry/types/metrics';
 } from 'sentry/types/metrics';
+import {aggregationToMetricType} from 'sentry/utils/metrics/extractionRules';
 import {DEFAULT_MRI, parseMRI} from 'sentry/utils/metrics/mri';
 import {DEFAULT_MRI, parseMRI} from 'sentry/utils/metrics/mri';
 import type {MetricTag} from 'sentry/utils/metrics/types';
 import type {MetricTag} from 'sentry/utils/metrics/types';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useApiQuery} from 'sentry/utils/queryClient';
@@ -73,19 +73,6 @@ export function createMRIToVirtualMap(rules: MetricsExtractionRule[]): Map<MRI,
   return mriMap;
   return mriMap;
 }
 }
 
 
-const aggregationToMetricType: Record<MetricAggregation, MetricType> = {
-  count: 'c',
-  count_unique: 's',
-  min: 'g',
-  max: 'g',
-  sum: 'g',
-  avg: 'g',
-  p50: 'd',
-  p75: 'd',
-  p95: 'd',
-  p99: 'd',
-};
-
 const getMetricsExtractionRulesApiKey = (orgSlug: string, projects: number[]) =>
 const getMetricsExtractionRulesApiKey = (orgSlug: string, projects: number[]) =>
   [
   [
     `/organizations/${orgSlug}/metrics/extraction-rules/`,
     `/organizations/${orgSlug}/metrics/extraction-rules/`,

+ 21 - 0
static/app/views/alerts/rules/metric/details/body.tsx

@@ -20,17 +20,21 @@ import {space} from 'sentry/styles/space';
 import {RuleActionsCategories} from 'sentry/types/alerts';
 import {RuleActionsCategories} from 'sentry/types/alerts';
 import type {Organization} from 'sentry/types/organization';
 import type {Organization} from 'sentry/types/organization';
 import type {Project} from 'sentry/types/project';
 import type {Project} from 'sentry/types/project';
+import {formatMRIField} from 'sentry/utils/metrics/mri';
 import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features';
 import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features';
 import {ErrorMigrationWarning} from 'sentry/views/alerts/rules/metric/details/errorMigrationWarning';
 import {ErrorMigrationWarning} from 'sentry/views/alerts/rules/metric/details/errorMigrationWarning';
 import MetricHistory from 'sentry/views/alerts/rules/metric/details/metricHistory';
 import MetricHistory from 'sentry/views/alerts/rules/metric/details/metricHistory';
 import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
 import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
 import {Dataset, TimePeriod} from 'sentry/views/alerts/rules/metric/types';
 import {Dataset, TimePeriod} from 'sentry/views/alerts/rules/metric/types';
 import {extractEventTypeFilterFromRule} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
 import {extractEventTypeFilterFromRule} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
+import {getFormattedSpanMetricField} from 'sentry/views/alerts/rules/metric/utils/getFormattedSpanMetric';
+import {isSpanMetricAlert} from 'sentry/views/alerts/rules/metric/utils/isSpanMetricAlert';
 import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
 import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
 import {getAlertRuleActionCategory} from 'sentry/views/alerts/rules/utils';
 import {getAlertRuleActionCategory} from 'sentry/views/alerts/rules/utils';
 import type {Incident} from 'sentry/views/alerts/types';
 import type {Incident} from 'sentry/views/alerts/types';
 import {AlertRuleStatus} from 'sentry/views/alerts/types';
 import {AlertRuleStatus} from 'sentry/views/alerts/types';
 import {alertDetailsLink} from 'sentry/views/alerts/utils';
 import {alertDetailsLink} from 'sentry/views/alerts/utils';
+import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/useMetricsExtractionRules';
 
 
 import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
 import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
 import {isCustomMetricAlert} from '../utils/isCustomMetricAlert';
 import {isCustomMetricAlert} from '../utils/isCustomMetricAlert';
@@ -68,6 +72,14 @@ export default function MetricDetailsBody({
   location,
   location,
   router,
   router,
 }: MetricDetailsBodyProps) {
 }: MetricDetailsBodyProps) {
+  const {data: metricExtractionRules} = useMetricsExtractionRules(
+    {
+      orgId: organization.slug,
+      projectId: project?.slug,
+    },
+    {enabled: isSpanMetricAlert(rule?.aggregate)}
+  );
+
   function getPeriodInterval() {
   function getPeriodInterval() {
     const startDate = moment.utc(timePeriod.start);
     const startDate = moment.utc(timePeriod.start);
     const endDate = moment.utc(timePeriod.end);
     const endDate = moment.utc(timePeriod.end);
@@ -158,6 +170,14 @@ export default function MetricDetailsBody({
     isOnDemandMetricAlert(dataset, aggregate, query) &&
     isOnDemandMetricAlert(dataset, aggregate, query) &&
     shouldShowOnDemandMetricAlertUI(organization);
     shouldShowOnDemandMetricAlertUI(organization);
 
 
+  let formattedAggregate = aggregate;
+  if (isCustomMetricAlert(aggregate)) {
+    formattedAggregate = formatMRIField(aggregate);
+  }
+  if (isSpanMetricAlert(aggregate)) {
+    formattedAggregate = getFormattedSpanMetricField(aggregate, metricExtractionRules);
+  }
+
   return (
   return (
     <Fragment>
     <Fragment>
       {selectedIncident?.alertRule.status === AlertRuleStatus.SNAPSHOT && (
       {selectedIncident?.alertRule.status === AlertRuleStatus.SNAPSHOT && (
@@ -222,6 +242,7 @@ export default function MetricDetailsBody({
             incidents={incidents}
             incidents={incidents}
             timePeriod={timePeriod}
             timePeriod={timePeriod}
             selectedIncident={selectedIncident}
             selectedIncident={selectedIncident}
+            formattedAggregate={formattedAggregate}
             organization={organization}
             organization={organization}
             project={project}
             project={project}
             interval={getPeriodInterval()}
             interval={getPeriodInterval()}

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