Skip to content

Commit

Permalink
feat(anomaly detection): add new anomaly detection alert fields to cr…
Browse files Browse the repository at this point in the history
…eate/update form (getsentry#76011)

If the anomaly detection alert type is selected, new fields pop up for
sensitivity and detection type. Also add the functionality for anomaly
detection alert create/update via the frontend.

Fixes https://getsentry.atlassian.net/browse/ALRT-143
Create process:


https://github.com/user-attachments/assets/618787ef-a56a-4a49-9643-05cabb4563a8

Created rule:
<img width="1512" alt="Screenshot 2024-08-14 at 11 56 42 AM"
src="https://github.com/user-attachments/assets/31683575-30f5-49e6-8a6b-d9e78380829a">
  • Loading branch information
mifu67 authored Aug 16, 2024
1 parent bbca510 commit 55ec3eb
Show file tree
Hide file tree
Showing 9 changed files with 328 additions and 25 deletions.
1 change: 1 addition & 0 deletions static/app/views/alerts/rules/metric/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export function createDefaultRule(
environment: null,
resolveThreshold: '',
thresholdType: AlertRuleThresholdType.ABOVE,
detectionType: AlertRuleComparisonType.COUNT,
...defaultRuleOptions,
};
}
Expand Down
11 changes: 9 additions & 2 deletions static/app/views/alerts/rules/metric/ruleConditionsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ import {getProjectOptions} from '../utils';

import {isCrashFreeAlert} from './utils/isCrashFreeAlert';
import {DEFAULT_AGGREGATE, DEFAULT_TRANSACTION_AGGREGATE} from './constants';
import type {AlertRuleComparisonType} from './types';
import {Dataset, Datasource, TimeWindow} from './types';
import {AlertRuleComparisonType, Dataset, Datasource, TimeWindow} from './types';

const TIME_WINDOW_MAP: Record<TimeWindow, string> = {
[TimeWindow.ONE_MINUTE]: t('1 minute'),
Expand Down Expand Up @@ -267,6 +266,14 @@ class RuleConditionsForm extends PureComponent<Props, State> {
]);
}

if (this.props.comparisonType === AlertRuleComparisonType.DYNAMIC) {
options = pick(TIME_WINDOW_MAP, [
TimeWindow.FIFTEEN_MINUTES,
TimeWindow.THIRTY_MINUTES,
TimeWindow.ONE_HOUR,
]);
}

return Object.entries(options).map(([value, label]) => ({
value: parseInt(value, 10),
label: tct('[timeWindow] interval', {
Expand Down
53 changes: 52 additions & 1 deletion static/app/views/alerts/rules/metric/ruleForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import ProjectsStore from 'sentry/stores/projectsStore';
import {ActivationConditionType, MonitorType} from 'sentry/types/alerts';
import {metric} from 'sentry/utils/analytics';
import RuleFormContainer from 'sentry/views/alerts/rules/metric/ruleForm';
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
import {
AlertRuleComparisonType,
AlertRuleSeasonality,
AlertRuleSensitivity,
Dataset,
} from 'sentry/views/alerts/rules/metric/types';
import {permissionAlertText} from 'sentry/views/settings/project/permissionAlert';

jest.mock('sentry/actionCreators/indicator');
Expand Down Expand Up @@ -351,6 +356,52 @@ describe('Incident Rules Form', () => {
);
});

it('creates an anomaly detection rule', async () => {
organization.features = [...organization.features, 'anomaly-detection-alerts'];
const rule = MetricRuleFixture({
detectionType: AlertRuleComparisonType.PERCENT,
sensitivity: AlertRuleSensitivity.MEDIUM,
seasonality: AlertRuleSeasonality.AUTO,
});
createWrapper({
rule: {
...rule,
id: undefined,
aggregate: 'count()',
eventTypes: ['error'],
dataset: 'events',
},
});
await userEvent.click(
screen.getByText('Anomaly: when evaluated values are outside of expected bounds')
);

expect(
await screen.findByLabelText(
'Anomaly: when evaluated values are outside of expected bounds'
)
).toBeChecked();
expect(
await screen.findByRole('textbox', {name: 'Sensitivity'})
).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Save Rule'));

expect(createRule).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
data: expect.objectContaining({
aggregate: 'count()',
dataset: 'events',
environment: null,
eventTypes: ['error'],
detectionType: AlertRuleComparisonType.DYNAMIC,
sensitivity: AlertRuleSensitivity.MEDIUM,
seasonality: AlertRuleSeasonality.AUTO,
}),
})
);
});

it('switches to custom metric and selects event.type:error', async () => {
organization.features = [...organization.features, 'performance-view'];
const rule = MetricRuleFixture();
Expand Down
85 changes: 72 additions & 13 deletions static/app/views/alerts/rules/metric/ruleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ import {
DEFAULT_COUNT_TIME_WINDOW,
} from './constants';
import RuleConditionsForm from './ruleConditionsForm';
import type {
EventTypes,
MetricActionTemplate,
MetricRule,
Trigger,
UnsavedMetricRule,
import {
AlertRuleSeasonality,
AlertRuleSensitivity,
type EventTypes,
type MetricActionTemplate,
type MetricRule,
type Trigger,
type UnsavedMetricRule,
} from './types';
import {
AlertRuleComparisonType,
Expand Down Expand Up @@ -142,6 +144,7 @@ type State = {
project: Project;
query: string;
resolveThreshold: UnsavedMetricRule['resolveThreshold'];
sensitivity: UnsavedMetricRule['sensitivity'];
thresholdPeriod: UnsavedMetricRule['thresholdPeriod'];
thresholdType: UnsavedMetricRule['thresholdType'];
timeWindow: number;
Expand All @@ -151,6 +154,7 @@ type State = {
comparisonDelta?: number;
isExtrapolatedChartData?: boolean;
monitorType?: MonitorType;
seasonality?: AlertRuleSeasonality;
} & DeprecatedAsyncComponent['state'];

const isEmpty = (str: unknown): boolean => str === '' || !defined(str);
Expand Down Expand Up @@ -233,6 +237,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
metricExtractionRules: null,
triggers: triggersClone,
resolveThreshold: rule.resolveThreshold,
sensitivity: null,
thresholdType: rule.thresholdType,
thresholdPeriod: rule.thresholdPeriod ?? 1,
comparisonDelta: rule.comparisonDelta ?? undefined,
Expand Down Expand Up @@ -455,7 +460,25 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
) {
const {comparisonType} = this.state;
const triggerErrors = new Map();

// If we have an anomaly detection alert, then we don't need to validate the thresholds, but we do need to set them to 0
if (comparisonType === AlertRuleComparisonType.DYNAMIC) {
// NOTE: we don't support warning triggers for anomaly detection alerts yet
// once we do, uncomment this code and delete 475-478:
// triggers.forEach(trigger => {
// trigger.alertThreshold = 0;
// });
const criticalTriggerIndex = triggers.findIndex(
({label}) => label === AlertRuleTriggerType.CRITICAL
);
const warningTriggerIndex = criticalTriggerIndex ^ 1;
const triggersCopy = [...triggers];
const criticalTrigger = triggersCopy[criticalTriggerIndex];
const warningTrigger = triggersCopy[warningTriggerIndex];
criticalTrigger.alertThreshold = 0;
warningTrigger.alertThreshold = ''; // we need to set this to empty
this.setState({triggers: triggersCopy});
return triggerErrors; // return an empty map
}
const requiredFields = ['label', 'alertThreshold'];
triggers.forEach((trigger, triggerIndex) => {
requiredFields.forEach(field => {
Expand Down Expand Up @@ -725,6 +748,9 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
eventTypes,
monitorType,
activationCondition,
sensitivity,
seasonality,
comparisonType,
} = this.state;
// Remove empty warning trigger
const sanitizedTriggers = triggers.filter(
Expand Down Expand Up @@ -761,7 +787,12 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
activationCondition,
};
}

const detectionTypes = new Map([
[AlertRuleComparisonType.COUNT, 'static'],
[AlertRuleComparisonType.CHANGE, 'percent'],
[AlertRuleComparisonType.DYNAMIC, 'dynamic'],
]);
const detectionType = detectionTypes.get(comparisonType) ?? '';
const dataset = this.determinePerformanceDataset();
this.setState({loading: true});
// Add or update is just the PUT/POST to the org alert-rules api
Expand All @@ -785,6 +816,9 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
eventTypes: isCrashFreeAlert(rule.dataset) ? undefined : eventTypes,
dataset,
queryType: DatasetMEPAlertQueryTypes[dataset],
sensitivity: sensitivity ?? null,
seasonality: seasonality ?? null,
detectionType: detectionType,
},
{
duplicateRule: this.isDuplicateRule ? 'true' : 'false',
Expand Down Expand Up @@ -819,7 +853,13 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
? err?.responseJSON
: Object.values(err?.responseJSON)
: [];
const apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
let apiErrors = '';
if (typeof errors[0] === 'object') {
// NOTE: this occurs if we get a TimeoutError when attempting to hit the Seer API
apiErrors = ': ' + errors[0].message;
} else {
apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
}
this.handleRuleSaveFailure(t('Unable to save alert%s', apiErrors));
}
});
Expand Down Expand Up @@ -850,6 +890,10 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
});
};

handleSensitivityChange = (sensitivity: AlertRuleSensitivity) => {
this.setState({sensitivity});
};

handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
const {triggers} = this.state;

Expand Down Expand Up @@ -883,13 +927,25 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {

handleComparisonTypeChange = (value: AlertRuleComparisonType) => {
const comparisonDelta =
value === AlertRuleComparisonType.COUNT
? undefined
: this.state.comparisonDelta ?? DEFAULT_CHANGE_COMP_DELTA;
value === AlertRuleComparisonType.CHANGE
? this.state.comparisonDelta ?? DEFAULT_CHANGE_COMP_DELTA
: undefined;
const timeWindow = this.state.comparisonDelta
? DEFAULT_COUNT_TIME_WINDOW
: DEFAULT_CHANGE_TIME_WINDOW;
this.setState({comparisonType: value, comparisonDelta, timeWindow});
const sensitivity =
value === AlertRuleComparisonType.DYNAMIC
? this.state.sensitivity || AlertRuleSensitivity.MEDIUM
: undefined;
const seasonality =
value === AlertRuleComparisonType.DYNAMIC ? AlertRuleSeasonality.AUTO : undefined; // TODO: replace "auto" with the correct constant
this.setState({
comparisonType: value,
comparisonDelta,
timeWindow,
sensitivity,
seasonality,
});
};

handleDeleteRule = async () => {
Expand Down Expand Up @@ -1096,6 +1152,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
comparisonDelta,
comparisonType,
resolveThreshold,
sensitivity,
loading,
eventTypes,
dataset,
Expand All @@ -1120,6 +1177,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
aggregate={aggregate}
isMigration={isMigration}
resolveThreshold={resolveThreshold}
sensitivity={sensitivity}
thresholdPeriod={thresholdPeriod}
thresholdType={thresholdType}
comparisonType={comparisonType}
Expand All @@ -1130,6 +1188,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
onThresholdTypeChange={this.handleThresholdTypeChange}
onThresholdPeriodChange={this.handleThresholdPeriodChange}
onResolveThresholdChange={this.handleResolveThresholdChange}
onSensitivityChange={this.handleSensitivityChange}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleMod
import ActionSpecificTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionSpecificTargetSelector';
import ActionTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionTargetSelector';
import DeleteActionButton from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/deleteActionButton';
import type {
Action,
ActionType,
MetricActionTemplate,
Trigger,
import {
type Action,
type ActionType,
AlertRuleComparisonType,
type MetricActionTemplate,
type Trigger,
} from 'sentry/views/alerts/rules/metric/types';
import {
ActionLabel,
Expand All @@ -39,6 +40,7 @@ import {

type Props = {
availableActions: MetricActionTemplate[] | null;
comparisonType: AlertRuleComparisonType;
currentProject: string;
disabled: boolean;
error: boolean;
Expand Down Expand Up @@ -311,6 +313,7 @@ class ActionsPanel extends PureComponent<Props> {
organization,
projects,
triggers,
comparisonType,
} = this.props;

const project = projects.find(({slug}) => slug === currentProject);
Expand All @@ -327,6 +330,10 @@ class ActionsPanel extends PureComponent<Props> {
{value: 1, label: 'Warning Status'},
];

// NOTE: we don't support warning triggers for anomaly detection alerts yet
// once we do, this can be deleted
const anomalyDetectionLevels = [{value: 0, label: 'Critical Status'}];

// Create single array of unsaved and saved trigger actions
// Sorted by date created ascending
const actions = triggers
Expand Down Expand Up @@ -371,7 +378,11 @@ class ActionsPanel extends PureComponent<Props> {
actionIdx
)}
value={triggerIndex}
options={levels}
options={
comparisonType === AlertRuleComparisonType.DYNAMIC
? anomalyDetectionLevels
: levels
}
/>
<SelectControl
name="select-action"
Expand Down
Loading

0 comments on commit 55ec3eb

Please sign in to comment.