Skip to content

Commit

Permalink
feat(alerts): Adds experimental EAP alerts as an option in the alerts…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
edwardgou-sentry authored Oct 17, 2024
1 parent 5165f63 commit bb2d814
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 6 deletions.
7 changes: 4 additions & 3 deletions static/app/views/alerts/rules/metric/details/body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')} : {}),
Expand Down
34 changes: 34 additions & 0 deletions static/app/views/alerts/rules/metric/eapField.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 140 additions & 0 deletions static/app/views/alerts/rules/metric/eapField.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 42 additions & 0 deletions static/app/views/alerts/rules/metric/ruleForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion static/app/views/alerts/rules/metric/ruleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions static/app/views/alerts/rules/metric/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export enum Dataset {
METRICS = 'metrics',
ISSUE_PLATFORM = 'search_issues',
REPLAYS = 'replays',
EVENTS_ANALYTICS_PLATFORM = 'events_analytics_platform',
}

export enum EventTypes {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) ||
Expand Down
18 changes: 18 additions & 0 deletions static/app/views/alerts/rules/metric/wizardField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -126,6 +128,14 @@ export default function WizardField({
},
]
: []),
...(hasEAPAlerts(organization)
? [
{
label: AlertWizardAlertNames.eap_metrics,
value: 'eap_metrics' as const,
},
]
: []),
],
},
{
Expand Down Expand Up @@ -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 =>
Expand Down
14 changes: 13 additions & 1 deletion static/app/views/alerts/wizard/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -48,7 +50,8 @@ export type AlertType =
| 'llm_tokens'
| 'llm_cost'
| 'insights_metrics'
| 'uptime_monitor';
| 'uptime_monitor'
| 'eap_metrics';

export enum MEPAlertsQueryType {
ERROR = 0,
Expand All @@ -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> = {
Expand All @@ -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'),
};

/**
Expand All @@ -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" />,
};

Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit bb2d814

Please sign in to comment.