Browse Source

feat(alerts): Adds experimental EAP alerts as an option in the alerts product (#79039)

This change adds EAP Alerts as an option to the create alerts ui, so
that we can enable testing of the experimental EAP dataset through the
alerts and subscriptions flow. This is just for prototyping and there is
no intention to ship this to GA as it currently is.
edwardgou-sentry 4 months ago
parent
commit
bb2d814566

+ 4 - 3
static/app/views/alerts/rules/metric/details/body.tsx

@@ -148,9 +148,10 @@ export default function MetricDetailsBody({
   const {dataset, aggregate, query} = rule;
 
   const eventType = extractEventTypeFilterFromRule(rule);
-  const queryWithTypeFilter = (
-    query ? `(${query}) AND (${eventType})` : eventType
-  ).trim();
+  const queryWithTypeFilter =
+    dataset === Dataset.EVENTS_ANALYTICS_PLATFORM
+      ? query
+      : (query ? `(${query}) AND (${eventType})` : eventType).trim();
   const relativeOptions = {
     ...SELECTOR_RELATIVE_PERIODS,
     ...(rule.timeWindow > 1 ? {[TimePeriod.FOURTEEN_DAYS]: t('Last 14 days')} : {}),

+ 34 - 0
static/app/views/alerts/rules/metric/eapField.spec.tsx

@@ -0,0 +1,34 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import EAPField from 'sentry/views/alerts/rules/metric/eapField';
+
+describe('EAPField', () => {
+  it('renders', () => {
+    const {project} = initializeOrg();
+    render(
+      <EAPField
+        aggregate={'count(span.duration)'}
+        onChange={() => {}}
+        project={project}
+      />
+    );
+    screen.getByText('count');
+    screen.getByText('span.duration');
+  });
+
+  it('should call onChange with the new aggregate string when switching aggregates', async () => {
+    const {project} = initializeOrg();
+    const onChange = jest.fn();
+    render(
+      <EAPField
+        aggregate={'count(span.duration)'}
+        onChange={onChange}
+        project={project}
+      />
+    );
+    await userEvent.click(screen.getByText('count'));
+    await userEvent.click(await screen.findByText('max'));
+    await waitFor(() => expect(onChange).toHaveBeenCalledWith('max(span.duration)', {}));
+  });
+});

+ 140 - 0
static/app/views/alerts/rules/metric/eapField.tsx

@@ -0,0 +1,140 @@
+import {useCallback, useEffect} from 'react';
+import styled from '@emotion/styled';
+
+import SelectControl from 'sentry/components/forms/controls/selectControl';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Project} from 'sentry/types/project';
+import {parseFunction} from 'sentry/utils/discover/fields';
+import {ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} from 'sentry/utils/fields';
+
+export const DEFAULT_EAP_FIELD = 'span.duration';
+export const DEFAULT_EAP_METRICS_ALERT_FIELD = `count(${DEFAULT_EAP_FIELD})`;
+
+interface Props {
+  aggregate: string;
+  onChange: (value: string, meta: Record<string, any>) => void;
+  project: Project;
+}
+
+// Use the same aggregates/operations available in the explore view
+const OPERATIONS = [
+  ...ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => ({
+    label: aggregate,
+    value: aggregate,
+  })),
+];
+
+// TODD(edward): Just hardcode the EAP fields for now. We should use SpanTagsProvider in the future to match the Explore UI.
+const EAP_FIELD_OPTIONS = [
+  {
+    name: 'span.duration',
+  },
+  {
+    name: 'span.self_time',
+  },
+];
+
+function EAPField({aggregate, onChange}: Props) {
+  // We parse out the aggregation and field from the aggregate string.
+  // This only works for aggregates with <= 1 argument.
+  const {
+    name: aggregation,
+    arguments: [field],
+  } = parseFunction(aggregate) ?? {arguments: [undefined]};
+
+  useEffect(() => {
+    const selectedMriMeta = EAP_FIELD_OPTIONS.find(metric => metric.name === field);
+    if (field && !selectedMriMeta) {
+      const newSelection = EAP_FIELD_OPTIONS[0];
+      if (newSelection) {
+        onChange(`count(${newSelection.name})`, {});
+      } else if (aggregate !== DEFAULT_EAP_METRICS_ALERT_FIELD) {
+        onChange(DEFAULT_EAP_METRICS_ALERT_FIELD, {});
+      }
+    }
+  }, [onChange, aggregate, aggregation, field]);
+
+  const handleFieldChange = useCallback(
+    option => {
+      const selectedMeta = EAP_FIELD_OPTIONS.find(metric => metric.name === option.value);
+      if (!selectedMeta) {
+        return;
+      }
+      onChange(`${aggregation}(${option.value})`, {});
+    },
+    [onChange, aggregation]
+  );
+
+  const handleOperationChange = useCallback(
+    option => {
+      if (field) {
+        onChange(`${option.value}(${field})`, {});
+      } else {
+        onChange(`${option.value}(${DEFAULT_EAP_FIELD})`, {});
+      }
+    },
+    [field, onChange]
+  );
+
+  // As SelectControl does not support an options size limit out of the box
+  // we work around it by using the async variant of the control
+  const getFieldOptions = useCallback((searchText: string) => {
+    const filteredMeta = EAP_FIELD_OPTIONS.filter(
+      ({name}) =>
+        searchText === '' || name.toLowerCase().includes(searchText.toLowerCase())
+    );
+
+    const options = filteredMeta.map(metric => {
+      return {
+        label: metric.name,
+        value: metric.name,
+      };
+    });
+    return options;
+  }, []);
+
+  // When using the async variant of SelectControl, we need to pass in an option object instead of just the value
+  const selectedOption = field && {
+    label: field,
+    value: field,
+  };
+
+  return (
+    <Wrapper>
+      <StyledSelectControl
+        searchable
+        placeholder={t('Select an operation')}
+        options={OPERATIONS}
+        value={aggregation}
+        onChange={handleOperationChange}
+      />
+      <StyledSelectControl
+        searchable
+        placeholder={t('Select a metric')}
+        noOptionsMessage={() =>
+          EAP_FIELD_OPTIONS.length === 0
+            ? t('No metrics in this project')
+            : t('No options')
+        }
+        async
+        defaultOptions={getFieldOptions('')}
+        loadOptions={searchText => Promise.resolve(getFieldOptions(searchText))}
+        filterOption={() => true}
+        value={selectedOption}
+        onChange={handleFieldChange}
+      />
+    </Wrapper>
+  );
+}
+
+export default EAPField;
+
+const Wrapper = styled('div')`
+  display: flex;
+  gap: ${space(1)};
+`;
+
+const StyledSelectControl = styled(SelectControl)`
+  width: 200px;
+`;

+ 42 - 0
static/app/views/alerts/rules/metric/ruleForm.spec.tsx

@@ -506,6 +506,48 @@ describe('Incident Rules Form', () => {
       );
       expect(metric.startSpan).toHaveBeenCalledWith({name: 'saveAlertRule'});
     });
+
+    it('creates an EAP metric rule', async () => {
+      const rule = MetricRuleFixture();
+      createWrapper({
+        rule: {
+          ...rule,
+          id: undefined,
+          eventTypes: [],
+          aggregate: 'count(span.duration)',
+          dataset: Dataset.EVENTS_ANALYTICS_PLATFORM,
+        },
+      });
+
+      // Clear field
+      await userEvent.clear(screen.getByPlaceholderText('Enter Alert Name'));
+
+      // Enter in name so we can submit
+      await userEvent.type(
+        screen.getByPlaceholderText('Enter Alert Name'),
+        'EAP Incident Rule'
+      );
+
+      // Set thresholdPeriod
+      await selectEvent.select(screen.getAllByText('For 1 minute')[0], 'For 10 minutes');
+
+      await userEvent.click(screen.getByLabelText('Save Rule'));
+
+      expect(createRule).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.objectContaining({
+          data: expect.objectContaining({
+            name: 'EAP Incident Rule',
+            projects: ['project-slug'],
+            eventTypes: [],
+            thresholdPeriod: 10,
+            alertType: 'eap_metrics',
+            dataset: 'events_analytics_platform',
+          }),
+        })
+      );
+      expect(metric.startSpan).toHaveBeenCalledWith({name: 'saveAlertRule'});
+    });
   });
 
   describe('Editing a rule', () => {

+ 3 - 1
static/app/views/alerts/rules/metric/ruleForm.tsx

@@ -178,7 +178,9 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
     const {alertType, query, eventTypes, dataset} = this.state;
     const eventTypeFilter = getEventTypeFilter(this.state.dataset, eventTypes);
     const queryWithTypeFilter = (
-      !['custom_metrics', 'span_metrics', 'insights_metrics'].includes(alertType)
+      !['custom_metrics', 'span_metrics', 'insights_metrics', 'eap_metrics'].includes(
+        alertType
+      )
         ? query
           ? `(${query}) AND (${eventTypeFilter})`
           : eventTypeFilter

+ 1 - 0
static/app/views/alerts/rules/metric/types.tsx

@@ -42,6 +42,7 @@ export enum Dataset {
   METRICS = 'metrics',
   ISSUE_PLATFORM = 'search_issues',
   REPLAYS = 'replays',
+  EVENTS_ANALYTICS_PLATFORM = 'events_analytics_platform',
 }
 
 export enum EventTypes {

+ 7 - 1
static/app/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras.tsx

@@ -7,7 +7,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
 import {getMEPAlertsDataset} from 'sentry/views/alerts/wizard/options';
 import {hasInsightsAlerts} from 'sentry/views/insights/common/utils/hasInsightsAlerts';
 
-import type {MetricRule} from '../types';
+import {Dataset, type MetricRule} from '../types';
 
 export function getMetricDatasetQueryExtras({
   organization,
@@ -22,6 +22,12 @@ export function getMetricDatasetQueryExtras({
   location?: Location;
   useOnDemandMetrics?: boolean;
 }) {
+  if (dataset === Dataset.EVENTS_ANALYTICS_PLATFORM) {
+    return {
+      dataset: 'spans',
+    };
+  }
+
   const hasMetricDataset =
     hasOnDemandMetricAlertFeature(organization) ||
     hasCustomMetrics(organization) ||

+ 18 - 0
static/app/views/alerts/rules/metric/wizardField.tsx

@@ -11,6 +11,7 @@ import type {Project} from 'sentry/types/project';
 import type {QueryFieldValue} from 'sentry/utils/discover/fields';
 import {explodeFieldString, generateFieldAsString} from 'sentry/utils/discover/fields';
 import {hasCustomMetrics} from 'sentry/utils/metrics/features';
+import EAPField from 'sentry/views/alerts/rules/metric/eapField';
 import InsightsMetricField from 'sentry/views/alerts/rules/metric/insightsMetricField';
 import MriField from 'sentry/views/alerts/rules/metric/mriField';
 import type {Dataset} from 'sentry/views/alerts/rules/metric/types';
@@ -22,6 +23,7 @@ import {
 import {QueryField} from 'sentry/views/discover/table/queryField';
 import {FieldValueKind} from 'sentry/views/discover/table/types';
 import {generateFieldOptions} from 'sentry/views/discover/utils';
+import {hasEAPAlerts} from 'sentry/views/insights/common/utils/hasEAPAlerts';
 import {hasInsightsAlerts} from 'sentry/views/insights/common/utils/hasInsightsAlerts';
 
 import {getFieldOptionConfig} from './metricField';
@@ -126,6 +128,14 @@ export default function WizardField({
               },
             ]
           : []),
+        ...(hasEAPAlerts(organization)
+          ? [
+              {
+                label: AlertWizardAlertNames.eap_metrics,
+                value: 'eap_metrics' as const,
+              },
+            ]
+          : []),
       ],
     },
     {
@@ -206,6 +216,14 @@ export default function WizardField({
                   return onChange(newAggregate, {});
                 }}
               />
+            ) : alertType === 'eap_metrics' ? (
+              <EAPField
+                project={project}
+                aggregate={aggregate}
+                onChange={newAggregate => {
+                  return onChange(newAggregate, {});
+                }}
+              />
             ) : (
               <StyledQueryField
                 filterPrimaryOptions={option =>

+ 13 - 1
static/app/views/alerts/wizard/options.tsx

@@ -22,11 +22,13 @@ import {
 } from 'sentry/utils/metrics/mri';
 import {ON_DEMAND_METRICS_UNSUPPORTED_TAGS} from 'sentry/utils/onDemandMetrics/constants';
 import {shouldShowOnDemandMetricAlertUI} from 'sentry/utils/onDemandMetrics/features';
+import {DEFAULT_EAP_METRICS_ALERT_FIELD} from 'sentry/views/alerts/rules/metric/eapField';
 import {
   Dataset,
   EventTypes,
   SessionsAggregate,
 } from 'sentry/views/alerts/rules/metric/types';
+import {hasEAPAlerts} from 'sentry/views/insights/common/utils/hasEAPAlerts';
 import {hasInsightsAlerts} from 'sentry/views/insights/common/utils/hasInsightsAlerts';
 import {MODULE_TITLE as LLM_MONITORING_MODULE_TITLE} from 'sentry/views/insights/llmMonitoring/settings';
 
@@ -48,7 +50,8 @@ export type AlertType =
   | 'llm_tokens'
   | 'llm_cost'
   | 'insights_metrics'
-  | 'uptime_monitor';
+  | 'uptime_monitor'
+  | 'eap_metrics';
 
 export enum MEPAlertsQueryType {
   ERROR = 0,
@@ -72,6 +75,7 @@ export const DatasetMEPAlertQueryTypes: Record<
   [Dataset.TRANSACTIONS]: MEPAlertsQueryType.PERFORMANCE,
   [Dataset.GENERIC_METRICS]: MEPAlertsQueryType.PERFORMANCE,
   [Dataset.METRICS]: MEPAlertsQueryType.CRASH_RATE,
+  [Dataset.EVENTS_ANALYTICS_PLATFORM]: MEPAlertsQueryType.PERFORMANCE,
 };
 
 export const AlertWizardAlertNames: Record<AlertType, string> = {
@@ -93,6 +97,7 @@ export const AlertWizardAlertNames: Record<AlertType, string> = {
   llm_tokens: t('LLM token usage'),
   insights_metrics: t('Insights Metric'),
   uptime_monitor: t('Uptime Monitor'),
+  eap_metrics: t('EAP Metric'),
 };
 
 /**
@@ -101,6 +106,7 @@ export const AlertWizardAlertNames: Record<AlertType, string> = {
  */
 export const AlertWizardExtraContent: Partial<Record<AlertType, React.ReactNode>> = {
   insights_metrics: <FeatureBadge type="alpha" />,
+  eap_metrics: <FeatureBadge type="experimental" />,
   uptime_monitor: <FeatureBadge type="beta" />,
 };
 
@@ -135,6 +141,7 @@ export const getAlertWizardCategories = (org: Organization) => {
         'cls',
         ...(hasCustomMetrics(org) ? (['custom_transactions'] satisfies AlertType[]) : []),
         ...(hasInsightsAlerts(org) ? ['insights_metrics' as const] : []),
+        ...(hasEAPAlerts(org) ? ['eap_metrics' as const] : []),
       ],
     });
     if (org.features.includes('insights-addon-modules')) {
@@ -247,6 +254,11 @@ export const AlertWizardRuleTemplates: Record<
     dataset: Dataset.METRICS,
     eventTypes: EventTypes.USER,
   },
+  eap_metrics: {
+    aggregate: DEFAULT_EAP_METRICS_ALERT_FIELD,
+    dataset: Dataset.EVENTS_ANALYTICS_PLATFORM,
+    eventTypes: EventTypes.TRANSACTION,
+  },
 };
 
 export const DEFAULT_WIZARD_TEMPLATE = AlertWizardRuleTemplates.num_errors;

+ 8 - 0
static/app/views/alerts/wizard/panelContent.tsx

@@ -194,4 +194,12 @@ export const AlertWizardPanelContent: Record<AlertType, PanelContent> = {
     ],
     illustration: diagramUptime,
   },
+  eap_metrics: {
+    description: t('Alert on eap metrics.'),
+    examples: [
+      t('When your average time in queue exceeds 100ms.'),
+      t('When your app runs more than 1000 queries in a minute.'),
+    ],
+    illustration: diagramCustomMetrics,
+  },
 };

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