mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
1168 lines
36 KiB
TypeScript
1168 lines
36 KiB
TypeScript
import type {ReactNode} from 'react';
|
|
import type {PlainRoute, RouteComponentProps} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
|
|
import type {Indicator} from 'sentry/actionCreators/indicator';
|
|
import {
|
|
addErrorMessage,
|
|
addSuccessMessage,
|
|
clearIndicators,
|
|
} from 'sentry/actionCreators/indicator';
|
|
import {fetchOrganizationTags} from 'sentry/actionCreators/tags';
|
|
import {hasEveryAccess} from 'sentry/components/acl/access';
|
|
import Alert from 'sentry/components/alert';
|
|
import {Button} from 'sentry/components/button';
|
|
import {HeaderTitleLegend} from 'sentry/components/charts/styles';
|
|
import CircleIndicator from 'sentry/components/circleIndicator';
|
|
import Confirm from 'sentry/components/confirm';
|
|
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
|
import type {FormProps} from 'sentry/components/forms/form';
|
|
import Form from 'sentry/components/forms/form';
|
|
import FormModel from 'sentry/components/forms/model';
|
|
import * as Layout from 'sentry/components/layouts/thirds';
|
|
import List from 'sentry/components/list';
|
|
import ListItem from 'sentry/components/list/listItem';
|
|
import {t, tct} from 'sentry/locale';
|
|
import IndicatorStore from 'sentry/stores/indicatorStore';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {
|
|
EventsStats,
|
|
MultiSeriesEventsStats,
|
|
Organization,
|
|
Project,
|
|
} from 'sentry/types';
|
|
import {defined} from 'sentry/utils';
|
|
import {metric, trackAnalytics} from 'sentry/utils/analytics';
|
|
import type EventView from 'sentry/utils/discover/eventView';
|
|
import {AggregationKey} from 'sentry/utils/fields';
|
|
import {
|
|
getForceMetricsLayerQueryExtras,
|
|
hasDDMFeature,
|
|
} from 'sentry/utils/metrics/features';
|
|
import {DEFAULT_METRIC_ALERT_FIELD, formatMRIField} from 'sentry/utils/metrics/mri';
|
|
import {isOnDemandQueryString} from 'sentry/utils/onDemandMetrics';
|
|
import {
|
|
hasOnDemandMetricAlertFeature,
|
|
shouldShowOnDemandMetricAlertUI,
|
|
} from 'sentry/utils/onDemandMetrics/features';
|
|
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
|
|
import withProjects from 'sentry/utils/withProjects';
|
|
import {IncompatibleAlertQuery} from 'sentry/views/alerts/rules/metric/incompatibleAlertQuery';
|
|
import RuleNameOwnerForm from 'sentry/views/alerts/rules/metric/ruleNameOwnerForm';
|
|
import ThresholdTypeForm from 'sentry/views/alerts/rules/metric/thresholdTypeForm';
|
|
import Triggers from 'sentry/views/alerts/rules/metric/triggers';
|
|
import TriggersChart from 'sentry/views/alerts/rules/metric/triggers/chart';
|
|
import {getEventTypeFilter} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
|
|
import hasThresholdValue from 'sentry/views/alerts/rules/metric/utils/hasThresholdValue';
|
|
import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
|
|
import {AlertRuleType} from 'sentry/views/alerts/types';
|
|
import {
|
|
hasIgnoreArchivedFeatureFlag,
|
|
ruleNeedsErrorMigration,
|
|
} from 'sentry/views/alerts/utils/migrationUi';
|
|
import type {MetricAlertType} from 'sentry/views/alerts/wizard/options';
|
|
import {
|
|
AlertWizardAlertNames,
|
|
DatasetMEPAlertQueryTypes,
|
|
} from 'sentry/views/alerts/wizard/options';
|
|
import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
|
|
import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
|
|
|
|
import {isCrashFreeAlert} from './utils/isCrashFreeAlert';
|
|
import {addOrUpdateRule} from './actions';
|
|
import {
|
|
createDefaultTrigger,
|
|
DEFAULT_CHANGE_COMP_DELTA,
|
|
DEFAULT_CHANGE_TIME_WINDOW,
|
|
DEFAULT_COUNT_TIME_WINDOW,
|
|
} from './constants';
|
|
import RuleConditionsForm from './ruleConditionsForm';
|
|
import type {
|
|
EventTypes,
|
|
MetricActionTemplate,
|
|
MetricRule,
|
|
Trigger,
|
|
UnsavedMetricRule,
|
|
} from './types';
|
|
import {
|
|
AlertRuleComparisonType,
|
|
AlertRuleThresholdType,
|
|
AlertRuleTriggerType,
|
|
Dataset,
|
|
} from './types';
|
|
|
|
const POLLING_MAX_TIME_LIMIT = 3 * 60000;
|
|
|
|
type RuleTaskResponse = {
|
|
status: 'pending' | 'failed' | 'success';
|
|
alertRule?: MetricRule;
|
|
error?: string;
|
|
};
|
|
|
|
type Props = {
|
|
organization: Organization;
|
|
project: Project;
|
|
projects: Project[];
|
|
routes: PlainRoute[];
|
|
rule: MetricRule;
|
|
userTeamIds: string[];
|
|
disableProjectSelector?: boolean;
|
|
eventView?: EventView;
|
|
isCustomMetric?: boolean;
|
|
isDuplicateRule?: boolean;
|
|
ruleId?: string;
|
|
sessionId?: string;
|
|
} & RouteComponentProps<{projectId?: string; ruleId?: string}, {}> & {
|
|
onSubmitSuccess?: FormProps['onSubmitSuccess'];
|
|
} & DeprecatedAsyncComponent['props'];
|
|
|
|
type State = {
|
|
aggregate: string;
|
|
alertType: MetricAlertType;
|
|
// `null` means loading
|
|
availableActions: MetricActionTemplate[] | null;
|
|
comparisonType: AlertRuleComparisonType;
|
|
// Rule conditions form inputs
|
|
// Needed for TriggersChart
|
|
dataset: Dataset;
|
|
environment: string | null;
|
|
eventTypes: EventTypes[];
|
|
isQueryValid: boolean;
|
|
project: Project;
|
|
query: string;
|
|
resolveThreshold: UnsavedMetricRule['resolveThreshold'];
|
|
thresholdPeriod: UnsavedMetricRule['thresholdPeriod'];
|
|
thresholdType: UnsavedMetricRule['thresholdType'];
|
|
timeWindow: number;
|
|
triggerErrors: Map<number, {[fieldName: string]: string}>;
|
|
triggers: Trigger[];
|
|
comparisonDelta?: number;
|
|
isExtrapolatedChartData?: boolean;
|
|
uuid?: string;
|
|
} & DeprecatedAsyncComponent['state'];
|
|
|
|
const isEmpty = (str: unknown): boolean => str === '' || !defined(str);
|
|
|
|
class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
|
|
form = new FormModel();
|
|
pollingTimeout: number | undefined = undefined;
|
|
|
|
get isDuplicateRule(): boolean {
|
|
return Boolean(this.props.isDuplicateRule);
|
|
}
|
|
|
|
get chartQuery(): string {
|
|
const {alertType, query, eventTypes, dataset} = this.state;
|
|
const eventTypeFilter = getEventTypeFilter(this.state.dataset, eventTypes);
|
|
const queryWithTypeFilter = (
|
|
alertType !== 'custom_metrics'
|
|
? query
|
|
? `(${query}) AND (${eventTypeFilter})`
|
|
: eventTypeFilter
|
|
: query
|
|
).trim();
|
|
return isCrashFreeAlert(dataset) ? query : queryWithTypeFilter;
|
|
}
|
|
|
|
componentDidMount() {
|
|
super.componentDidMount();
|
|
const {organization} = this.props;
|
|
const {project} = this.state;
|
|
// SearchBar gets its tags from Reflux.
|
|
fetchOrganizationTags(this.api, organization.slug, [project.id]);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
window.clearTimeout(this.pollingTimeout);
|
|
}
|
|
|
|
getDefaultState(): State {
|
|
const {rule, location} = this.props;
|
|
const triggersClone = [...rule.triggers];
|
|
const {
|
|
aggregate: _aggregate,
|
|
eventTypes: _eventTypes,
|
|
dataset: _dataset,
|
|
name,
|
|
} = location?.query ?? {};
|
|
const eventTypes = typeof _eventTypes === 'string' ? [_eventTypes] : _eventTypes;
|
|
|
|
// Warning trigger is removed if it is blank when saving
|
|
if (triggersClone.length !== 2) {
|
|
triggersClone.push(createDefaultTrigger(AlertRuleTriggerType.WARNING));
|
|
}
|
|
|
|
const aggregate = _aggregate ?? rule.aggregate;
|
|
const dataset = _dataset ?? rule.dataset;
|
|
|
|
const isErrorMigration =
|
|
this.props.location?.query?.migration === '1' &&
|
|
hasIgnoreArchivedFeatureFlag(this.props.organization) &&
|
|
ruleNeedsErrorMigration(rule);
|
|
// TODO(issues): Does this need to be smarter about where its inserting the new filter?
|
|
const query = isErrorMigration
|
|
? `is:unresolved ${rule.query ?? ''}`
|
|
: rule.query ?? '';
|
|
|
|
return {
|
|
...super.getDefaultState(),
|
|
|
|
name: name ?? rule.name ?? '',
|
|
aggregate,
|
|
dataset,
|
|
eventTypes: eventTypes ?? rule.eventTypes ?? [],
|
|
query,
|
|
isQueryValid: true, // Assume valid until input is changed
|
|
timeWindow: rule.timeWindow,
|
|
environment: rule.environment || null,
|
|
triggerErrors: new Map(),
|
|
availableActions: null,
|
|
triggers: triggersClone,
|
|
resolveThreshold: rule.resolveThreshold,
|
|
thresholdType: rule.thresholdType,
|
|
thresholdPeriod: rule.thresholdPeriod ?? 1,
|
|
comparisonDelta: rule.comparisonDelta ?? undefined,
|
|
comparisonType: rule.comparisonDelta
|
|
? AlertRuleComparisonType.CHANGE
|
|
: AlertRuleComparisonType.COUNT,
|
|
project: this.props.project,
|
|
owner: rule.owner,
|
|
alertType: getAlertTypeFromAggregateDataset({aggregate, dataset}),
|
|
};
|
|
}
|
|
|
|
getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
|
|
const {organization} = this.props;
|
|
// TODO(incidents): This is temporary until new API endpoints
|
|
// We should be able to just fetch the rule if rule.id exists
|
|
|
|
return [
|
|
[
|
|
'availableActions',
|
|
`/organizations/${organization.slug}/alert-rules/available-actions/`,
|
|
],
|
|
];
|
|
}
|
|
|
|
goBack() {
|
|
const {router} = this.props;
|
|
const {organization} = this.props;
|
|
|
|
router.push(normalizeUrl(`/organizations/${organization.slug}/alerts/rules/`));
|
|
}
|
|
|
|
resetPollingState = (loadingSlackIndicator: Indicator) => {
|
|
IndicatorStore.remove(loadingSlackIndicator);
|
|
this.setState({loading: false, uuid: undefined});
|
|
};
|
|
|
|
fetchStatus(model: FormModel) {
|
|
const loadingSlackIndicator = IndicatorStore.addMessage(
|
|
t('Looking for your slack channel (this can take a while)'),
|
|
'loading'
|
|
);
|
|
// pollHandler calls itself until it gets either a success
|
|
// or failed status but we don't want to poll forever so we pass
|
|
// in a hard stop time of 3 minutes before we bail.
|
|
const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
|
|
window.clearTimeout(this.pollingTimeout);
|
|
this.pollingTimeout = window.setTimeout(() => {
|
|
this.pollHandler(model, quitTime, loadingSlackIndicator);
|
|
}, 1000);
|
|
}
|
|
|
|
pollHandler = async (
|
|
model: FormModel,
|
|
quitTime: number,
|
|
loadingSlackIndicator: Indicator
|
|
) => {
|
|
if (Date.now() > quitTime) {
|
|
addErrorMessage(t('Looking for that channel took too long :('));
|
|
this.resetPollingState(loadingSlackIndicator);
|
|
return;
|
|
}
|
|
|
|
const {
|
|
organization,
|
|
onSubmitSuccess,
|
|
params: {ruleId},
|
|
} = this.props;
|
|
const {uuid, project} = this.state;
|
|
|
|
try {
|
|
const response: RuleTaskResponse = await this.api.requestPromise(
|
|
`/projects/${organization.slug}/${project.slug}/alert-rule-task/${uuid}/`
|
|
);
|
|
|
|
const {status, alertRule, error} = response;
|
|
|
|
if (status === 'pending') {
|
|
window.clearTimeout(this.pollingTimeout);
|
|
|
|
this.pollingTimeout = window.setTimeout(() => {
|
|
this.pollHandler(model, quitTime, loadingSlackIndicator);
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
this.resetPollingState(loadingSlackIndicator);
|
|
|
|
if (status === 'failed') {
|
|
this.handleRuleSaveFailure(error);
|
|
}
|
|
if (alertRule) {
|
|
addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
|
|
if (onSubmitSuccess) {
|
|
onSubmitSuccess(alertRule, model);
|
|
}
|
|
}
|
|
} catch {
|
|
this.handleRuleSaveFailure(t('An error occurred'));
|
|
this.resetPollingState(loadingSlackIndicator);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Checks to see if threshold is valid given target value, and state of
|
|
* inverted threshold as well as the *other* threshold
|
|
*
|
|
* @param type The threshold type to be updated
|
|
* @param value The new threshold value
|
|
*/
|
|
isValidTrigger = (
|
|
triggerIndex: number,
|
|
trigger: Trigger,
|
|
errors,
|
|
resolveThreshold: number | '' | null
|
|
): boolean => {
|
|
const {alertThreshold} = trigger;
|
|
const {thresholdType} = this.state;
|
|
|
|
// If value and/or other value is empty
|
|
// then there are no checks to perform against
|
|
if (!hasThresholdValue(alertThreshold) || !hasThresholdValue(resolveThreshold)) {
|
|
return true;
|
|
}
|
|
|
|
// If this is alert threshold and not inverted, it can't be below resolve
|
|
// If this is alert threshold and inverted, it can't be above resolve
|
|
// If this is resolve threshold and not inverted, it can't be above resolve
|
|
// If this is resolve threshold and inverted, it can't be below resolve
|
|
// Since we're comparing non-inclusive thresholds here (>, <), we need
|
|
// to modify the values when we compare. An example of why:
|
|
// Alert > 0, resolve < 1. This means that we want to alert on values
|
|
// of 1 or more, and resolve on values of 0 or less. This is valid, but
|
|
// without modifying the values, this boundary case will fail.
|
|
const isValid =
|
|
thresholdType === AlertRuleThresholdType.BELOW
|
|
? alertThreshold - 1 < resolveThreshold + 1
|
|
: alertThreshold + 1 > resolveThreshold - 1;
|
|
|
|
const otherErrors = errors.get(triggerIndex) || {};
|
|
|
|
if (isValid) {
|
|
return true;
|
|
}
|
|
|
|
// Not valid... let's figure out an error message
|
|
const isBelow = thresholdType === AlertRuleThresholdType.BELOW;
|
|
let errorMessage = '';
|
|
|
|
if (typeof resolveThreshold !== 'number') {
|
|
errorMessage = isBelow
|
|
? t('Resolution threshold must be greater than alert')
|
|
: t('Resolution threshold must be less than alert');
|
|
} else {
|
|
errorMessage = isBelow
|
|
? t('Alert threshold must be less than resolution')
|
|
: t('Alert threshold must be greater than resolution');
|
|
}
|
|
|
|
errors.set(triggerIndex, {
|
|
...otherErrors,
|
|
alertThreshold: errorMessage,
|
|
});
|
|
|
|
return false;
|
|
};
|
|
|
|
validateFieldInTrigger({errors, triggerIndex, field, message, isValid}) {
|
|
// If valid, reset error for fieldName
|
|
if (isValid()) {
|
|
const {[field]: _validatedField, ...otherErrors} = errors.get(triggerIndex) || {};
|
|
|
|
if (Object.keys(otherErrors).length > 0) {
|
|
errors.set(triggerIndex, otherErrors);
|
|
} else {
|
|
errors.delete(triggerIndex);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
if (!errors.has(triggerIndex)) {
|
|
errors.set(triggerIndex, {});
|
|
}
|
|
const currentErrors = errors.get(triggerIndex);
|
|
|
|
errors.set(triggerIndex, {
|
|
...currentErrors,
|
|
[field]: message,
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Validate triggers
|
|
*
|
|
* @return Returns true if triggers are valid
|
|
*/
|
|
validateTriggers(
|
|
triggers = this.state.triggers,
|
|
thresholdType = this.state.thresholdType,
|
|
resolveThreshold = this.state.resolveThreshold,
|
|
changedTriggerIndex?: number
|
|
) {
|
|
const {comparisonType} = this.state;
|
|
const triggerErrors = new Map();
|
|
|
|
const requiredFields = ['label', 'alertThreshold'];
|
|
triggers.forEach((trigger, triggerIndex) => {
|
|
requiredFields.forEach(field => {
|
|
// check required fields
|
|
this.validateFieldInTrigger({
|
|
errors: triggerErrors,
|
|
triggerIndex,
|
|
isValid: (): boolean => {
|
|
if (trigger.label === AlertRuleTriggerType.CRITICAL) {
|
|
return !isEmpty(trigger[field]);
|
|
}
|
|
|
|
// If warning trigger has actions, it must have a value
|
|
return trigger.actions.length === 0 || !isEmpty(trigger[field]);
|
|
},
|
|
field,
|
|
message: t('Field is required'),
|
|
});
|
|
});
|
|
|
|
// Check thresholds
|
|
this.isValidTrigger(
|
|
changedTriggerIndex ?? triggerIndex,
|
|
trigger,
|
|
triggerErrors,
|
|
resolveThreshold
|
|
);
|
|
});
|
|
|
|
// If we have 2 triggers, we need to make sure that the critical and warning
|
|
// alert thresholds are valid (e.g. if critical is above x, warning must be less than x)
|
|
const criticalTriggerIndex = triggers.findIndex(
|
|
({label}) => label === AlertRuleTriggerType.CRITICAL
|
|
);
|
|
const warningTriggerIndex = criticalTriggerIndex ^ 1;
|
|
const criticalTrigger = triggers[criticalTriggerIndex];
|
|
const warningTrigger = triggers[warningTriggerIndex];
|
|
|
|
const isEmptyWarningThreshold = isEmpty(warningTrigger.alertThreshold);
|
|
const warningThreshold = warningTrigger.alertThreshold ?? 0;
|
|
const criticalThreshold = criticalTrigger.alertThreshold ?? 0;
|
|
|
|
const hasError =
|
|
thresholdType === AlertRuleThresholdType.ABOVE ||
|
|
comparisonType === AlertRuleComparisonType.CHANGE
|
|
? warningThreshold > criticalThreshold
|
|
: warningThreshold < criticalThreshold;
|
|
|
|
if (hasError && !isEmptyWarningThreshold) {
|
|
[criticalTriggerIndex, warningTriggerIndex].forEach(index => {
|
|
const otherErrors = triggerErrors.get(index) ?? {};
|
|
triggerErrors.set(index, {
|
|
...otherErrors,
|
|
alertThreshold:
|
|
thresholdType === AlertRuleThresholdType.ABOVE ||
|
|
comparisonType === AlertRuleComparisonType.CHANGE
|
|
? t('Warning threshold must be less than critical threshold')
|
|
: t('Warning threshold must be greater than critical threshold'),
|
|
});
|
|
});
|
|
}
|
|
|
|
return triggerErrors;
|
|
}
|
|
|
|
validateMri = () => {
|
|
const {aggregate} = this.state;
|
|
return aggregate !== DEFAULT_METRIC_ALERT_FIELD;
|
|
};
|
|
|
|
handleFieldChange = (name: string, value: unknown) => {
|
|
const {projects} = this.props;
|
|
|
|
if (name === 'alertType') {
|
|
this.setState(({dataset}) => ({
|
|
alertType: value as MetricAlertType,
|
|
dataset: this.checkOnDemandMetricsDataset(dataset, this.state.query),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
[
|
|
'aggregate',
|
|
'dataset',
|
|
'eventTypes',
|
|
'timeWindow',
|
|
'environment',
|
|
'comparisonDelta',
|
|
'projectId',
|
|
'alertType',
|
|
].includes(name)
|
|
) {
|
|
this.setState(({project: _project, dataset: _dataset, aggregate, alertType}) => {
|
|
const dataset = this.checkOnDemandMetricsDataset(
|
|
name === 'dataset' ? (value as Dataset) : _dataset,
|
|
this.state.query
|
|
);
|
|
|
|
const newAlertType = getAlertTypeFromAggregateDataset({
|
|
aggregate,
|
|
dataset,
|
|
});
|
|
|
|
return {
|
|
[name]: value,
|
|
project:
|
|
name === 'projectId' ? projects.find(({id}) => id === value) : _project,
|
|
alertType: alertType !== newAlertType ? 'custom_transactions' : alertType,
|
|
dataset,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
// We handle the filter update outside of the fieldChange handler since we
|
|
// don't want to update the filter on every input change, just on blurs and
|
|
// searches.
|
|
handleFilterUpdate = (query: string, isQueryValid: boolean) => {
|
|
const {organization, sessionId} = this.props;
|
|
|
|
trackAnalytics('alert_builder.filter', {
|
|
organization,
|
|
session_id: sessionId,
|
|
query,
|
|
});
|
|
|
|
const dataset = this.checkOnDemandMetricsDataset(this.state.dataset, query);
|
|
this.setState({query, dataset, isQueryValid});
|
|
};
|
|
|
|
validateOnDemandMetricAlert() {
|
|
if (
|
|
!isOnDemandMetricAlert(this.state.dataset, this.state.aggregate, this.state.query)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return !this.state.aggregate.includes(AggregationKey.PERCENTILE);
|
|
}
|
|
|
|
handleSubmit = async (
|
|
_data: Partial<MetricRule>,
|
|
_onSubmitSuccess,
|
|
_onSubmitError,
|
|
_e,
|
|
model: FormModel
|
|
) => {
|
|
if (!this.validateMri()) {
|
|
addErrorMessage(t('You need to select a metric before you can save the alert'));
|
|
return;
|
|
}
|
|
// This validates all fields *except* for Triggers
|
|
const validRule = model.validateForm();
|
|
|
|
// Validate Triggers
|
|
const triggerErrors = this.validateTriggers();
|
|
const validTriggers = Array.from(triggerErrors).length === 0;
|
|
const validOnDemandAlert = this.validateOnDemandMetricAlert();
|
|
|
|
if (!validTriggers) {
|
|
this.setState(state => ({
|
|
triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
|
|
}));
|
|
}
|
|
|
|
if (!validRule || !validTriggers) {
|
|
const missingFields = [
|
|
!validRule && t('name'),
|
|
!validRule && !validTriggers && t('and'),
|
|
!validTriggers && t('critical threshold'),
|
|
].filter(x => x);
|
|
|
|
addErrorMessage(t('Alert not valid: missing %s', missingFields.join(' ')));
|
|
return;
|
|
}
|
|
|
|
if (!validOnDemandAlert) {
|
|
addErrorMessage(
|
|
t('%s is not supported for on-demand metric alerts', this.state.aggregate)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const {
|
|
organization,
|
|
rule,
|
|
onSubmitSuccess,
|
|
location,
|
|
sessionId,
|
|
params: {ruleId},
|
|
} = this.props;
|
|
const {
|
|
project,
|
|
aggregate,
|
|
resolveThreshold,
|
|
triggers,
|
|
thresholdType,
|
|
thresholdPeriod,
|
|
comparisonDelta,
|
|
uuid,
|
|
timeWindow,
|
|
eventTypes,
|
|
} = this.state;
|
|
// Remove empty warning trigger
|
|
const sanitizedTriggers = triggers.filter(
|
|
trigger =>
|
|
trigger.label !== AlertRuleTriggerType.WARNING || !isEmpty(trigger.alertThreshold)
|
|
);
|
|
|
|
// form model has all form state data, however we use local state to keep
|
|
// track of the list of triggers (and actions within triggers)
|
|
const loadingIndicator = IndicatorStore.addMessage(
|
|
t('Saving your alert rule, hold on...'),
|
|
'loading'
|
|
);
|
|
try {
|
|
const transaction = metric.startTransaction({name: 'saveAlertRule'});
|
|
transaction.setTag('type', AlertRuleType.METRIC);
|
|
transaction.setTag('operation', !rule.id ? 'create' : 'edit');
|
|
for (const trigger of sanitizedTriggers) {
|
|
for (const action of trigger.actions) {
|
|
if (action.type === 'slack' || action.type === 'discord') {
|
|
transaction.setTag(action.type, true);
|
|
}
|
|
}
|
|
}
|
|
transaction.setData('actions', sanitizedTriggers);
|
|
|
|
const dataset = this.determinePerformanceDataset();
|
|
this.setState({loading: true});
|
|
const [data, , resp] = await addOrUpdateRule(
|
|
this.api,
|
|
organization.slug,
|
|
{
|
|
...rule,
|
|
...model.getTransformedData(),
|
|
projects: [project.slug],
|
|
triggers: sanitizedTriggers,
|
|
resolveThreshold: isEmpty(resolveThreshold) ? null : resolveThreshold,
|
|
thresholdType,
|
|
thresholdPeriod,
|
|
comparisonDelta: comparisonDelta ?? null,
|
|
timeWindow,
|
|
aggregate,
|
|
// Remove eventTypes as it is no longer required for crash free
|
|
eventTypes: isCrashFreeAlert(rule.dataset) ? undefined : eventTypes,
|
|
dataset,
|
|
queryType: DatasetMEPAlertQueryTypes[dataset],
|
|
},
|
|
{
|
|
duplicateRule: this.isDuplicateRule ? 'true' : 'false',
|
|
wizardV3: 'true',
|
|
referrer: location?.query?.referrer,
|
|
sessionId,
|
|
...getForceMetricsLayerQueryExtras(organization, dataset),
|
|
}
|
|
);
|
|
// if we get a 202 back it means that we have an async task
|
|
// running to lookup and verify the channel id for Slack.
|
|
if (resp?.status === 202) {
|
|
// if we have a uuid in state, no need to start a new polling cycle
|
|
if (!uuid) {
|
|
this.setState({loading: true, uuid: data.uuid});
|
|
this.fetchStatus(model);
|
|
}
|
|
} else {
|
|
IndicatorStore.remove(loadingIndicator);
|
|
this.setState({loading: false});
|
|
addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
|
|
if (onSubmitSuccess) {
|
|
onSubmitSuccess(data, model);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
IndicatorStore.remove(loadingIndicator);
|
|
this.setState({loading: false});
|
|
const errors = err?.responseJSON
|
|
? Array.isArray(err?.responseJSON)
|
|
? err?.responseJSON
|
|
: Object.values(err?.responseJSON)
|
|
: [];
|
|
const apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
|
|
this.handleRuleSaveFailure(t('Unable to save alert%s', apiErrors));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Callback for when triggers change
|
|
*
|
|
* Re-validate triggers on every change and reset indicators when no errors
|
|
*/
|
|
handleChangeTriggers = (triggers: Trigger[], triggerIndex?: number) => {
|
|
this.setState(state => {
|
|
let triggerErrors = state.triggerErrors;
|
|
|
|
const newTriggerErrors = this.validateTriggers(
|
|
triggers,
|
|
state.thresholdType,
|
|
state.resolveThreshold,
|
|
triggerIndex
|
|
);
|
|
triggerErrors = newTriggerErrors;
|
|
|
|
if (Array.from(newTriggerErrors).length === 0) {
|
|
clearIndicators();
|
|
}
|
|
|
|
return {triggers, triggerErrors, triggersHaveChanged: true};
|
|
});
|
|
};
|
|
|
|
handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
|
|
const {triggers} = this.state;
|
|
|
|
const triggerErrors = this.validateTriggers(triggers, thresholdType);
|
|
this.setState(state => ({
|
|
thresholdType,
|
|
triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
|
|
}));
|
|
};
|
|
|
|
handleThresholdPeriodChange = (value: number) => {
|
|
this.setState({thresholdPeriod: value});
|
|
};
|
|
|
|
handleResolveThresholdChange = (
|
|
resolveThreshold: UnsavedMetricRule['resolveThreshold']
|
|
) => {
|
|
this.setState(state => {
|
|
const triggerErrors = this.validateTriggers(
|
|
state.triggers,
|
|
state.thresholdType,
|
|
resolveThreshold
|
|
);
|
|
if (Array.from(triggerErrors).length === 0) {
|
|
clearIndicators();
|
|
}
|
|
|
|
return {resolveThreshold, triggerErrors};
|
|
});
|
|
};
|
|
|
|
handleComparisonTypeChange = (value: AlertRuleComparisonType) => {
|
|
const comparisonDelta =
|
|
value === AlertRuleComparisonType.COUNT
|
|
? undefined
|
|
: this.state.comparisonDelta ?? DEFAULT_CHANGE_COMP_DELTA;
|
|
const timeWindow = this.state.comparisonDelta
|
|
? DEFAULT_COUNT_TIME_WINDOW
|
|
: DEFAULT_CHANGE_TIME_WINDOW;
|
|
this.setState({comparisonType: value, comparisonDelta, timeWindow});
|
|
};
|
|
|
|
handleDeleteRule = async () => {
|
|
const {organization, params} = this.props;
|
|
const {ruleId} = params;
|
|
|
|
try {
|
|
await this.api.requestPromise(
|
|
`/organizations/${organization.slug}/alert-rules/${ruleId}/`,
|
|
{
|
|
method: 'DELETE',
|
|
}
|
|
);
|
|
this.goBack();
|
|
} catch (_err) {
|
|
addErrorMessage(t('Error deleting rule'));
|
|
}
|
|
};
|
|
|
|
handleRuleSaveFailure = (msg: ReactNode) => {
|
|
addErrorMessage(msg);
|
|
metric.endTransaction({name: 'saveAlertRule'});
|
|
};
|
|
|
|
handleCancel = () => {
|
|
this.goBack();
|
|
};
|
|
|
|
handleMEPAlertDataset = (data: EventsStats | MultiSeriesEventsStats | null) => {
|
|
const {isMetricsData} = data ?? {};
|
|
const {organization} = this.props;
|
|
|
|
if (
|
|
isMetricsData === undefined ||
|
|
!organization.features.includes('mep-rollout-flag')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const {dataset} = this.state;
|
|
if (isMetricsData && dataset === Dataset.TRANSACTIONS) {
|
|
this.setState({dataset: Dataset.GENERIC_METRICS});
|
|
}
|
|
|
|
if (!isMetricsData && dataset === Dataset.GENERIC_METRICS) {
|
|
this.setState({dataset: Dataset.TRANSACTIONS});
|
|
}
|
|
};
|
|
|
|
handleTimeSeriesDataFetched = (data: EventsStats | MultiSeriesEventsStats | null) => {
|
|
const {isExtrapolatedData} = data ?? {};
|
|
|
|
if (shouldShowOnDemandMetricAlertUI(this.props.organization)) {
|
|
this.setState({isExtrapolatedChartData: Boolean(isExtrapolatedData)});
|
|
}
|
|
|
|
const {dataset, aggregate, query} = this.state;
|
|
if (!isOnDemandMetricAlert(dataset, aggregate, query)) {
|
|
this.handleMEPAlertDataset(data);
|
|
}
|
|
};
|
|
|
|
// If the user is creating an on-demand metric alert, we want to override the dataset
|
|
// to be generic metrics instead of transactions
|
|
checkOnDemandMetricsDataset = (dataset: Dataset, query: string) => {
|
|
if (!hasOnDemandMetricAlertFeature(this.props.organization)) {
|
|
return dataset;
|
|
}
|
|
if (dataset !== Dataset.TRANSACTIONS || !isOnDemandQueryString(query)) {
|
|
return dataset;
|
|
}
|
|
return Dataset.GENERIC_METRICS;
|
|
};
|
|
|
|
// We are not allowing the creation of new transaction alerts
|
|
determinePerformanceDataset = () => {
|
|
// TODO: once all alerts are migrated to MEP, we can set the default to GENERIC_METRICS and remove this as well as
|
|
// logic in handleMEPDataset, handleTimeSeriesDataFetched and checkOnDemandMetricsDataset
|
|
const {dataset} = this.state;
|
|
const {organization} = this.props;
|
|
const hasMetricsFeatureFlags =
|
|
organization.features.includes('mep-rollout-flag') ||
|
|
hasOnDemandMetricAlertFeature(organization);
|
|
|
|
if (hasMetricsFeatureFlags && dataset === Dataset.TRANSACTIONS) {
|
|
return Dataset.GENERIC_METRICS;
|
|
}
|
|
return dataset;
|
|
};
|
|
|
|
renderLoading() {
|
|
return this.renderBody();
|
|
}
|
|
|
|
renderTriggerChart() {
|
|
const {organization, ruleId, rule, location} = this.props;
|
|
|
|
const {
|
|
query,
|
|
project,
|
|
timeWindow,
|
|
triggers,
|
|
aggregate,
|
|
environment,
|
|
thresholdType,
|
|
comparisonDelta,
|
|
comparisonType,
|
|
resolveThreshold,
|
|
eventTypes,
|
|
dataset,
|
|
alertType,
|
|
isQueryValid,
|
|
} = this.state;
|
|
|
|
const isOnDemand = isOnDemandMetricAlert(dataset, aggregate, query);
|
|
|
|
const chartProps = {
|
|
organization,
|
|
projects: [project],
|
|
triggers,
|
|
location,
|
|
query: this.chartQuery,
|
|
aggregate,
|
|
dataset,
|
|
newAlertOrQuery: !ruleId || query !== rule.query,
|
|
timeWindow,
|
|
environment,
|
|
resolveThreshold,
|
|
thresholdType,
|
|
comparisonDelta,
|
|
comparisonType,
|
|
isQueryValid,
|
|
isOnDemandMetricAlert: isOnDemand,
|
|
showTotalCount: alertType !== 'custom_metrics' && !isOnDemand,
|
|
onDataLoaded: this.handleTimeSeriesDataFetched,
|
|
};
|
|
|
|
const wizardBuilderChart = (
|
|
<TriggersChart
|
|
{...chartProps}
|
|
header={
|
|
<ChartHeader>
|
|
<AlertName>{AlertWizardAlertNames[alertType]}</AlertName>
|
|
{!isCrashFreeAlert(dataset) && (
|
|
<AlertInfo>
|
|
<StyledCircleIndicator size={8} />
|
|
<Aggregate>{formatMRIField(aggregate)}</Aggregate>
|
|
{alertType !== 'custom_metrics'
|
|
? `event.type:${eventTypes?.join(',')}`
|
|
: ''}
|
|
</AlertInfo>
|
|
)}
|
|
</ChartHeader>
|
|
}
|
|
/>
|
|
);
|
|
|
|
return wizardBuilderChart;
|
|
}
|
|
|
|
renderBody() {
|
|
const {
|
|
organization,
|
|
ruleId,
|
|
rule,
|
|
onSubmitSuccess,
|
|
router,
|
|
disableProjectSelector,
|
|
eventView,
|
|
location,
|
|
} = this.props;
|
|
const {
|
|
name,
|
|
query,
|
|
project,
|
|
timeWindow,
|
|
triggers,
|
|
aggregate,
|
|
thresholdType,
|
|
thresholdPeriod,
|
|
comparisonDelta,
|
|
comparisonType,
|
|
resolveThreshold,
|
|
loading,
|
|
eventTypes,
|
|
dataset,
|
|
alertType,
|
|
isExtrapolatedChartData,
|
|
triggersHaveChanged,
|
|
} = this.state;
|
|
|
|
const wizardBuilderChart = this.renderTriggerChart();
|
|
// TODO(issues): Remove this and all connected logic once the migration is complete
|
|
const isMigration = location?.query?.migration === '1';
|
|
|
|
const triggerForm = (disabled: boolean) => (
|
|
<Triggers
|
|
disabled={disabled}
|
|
projects={[project]}
|
|
errors={this.state.triggerErrors}
|
|
triggers={triggers}
|
|
aggregate={aggregate}
|
|
isMigration={isMigration}
|
|
resolveThreshold={resolveThreshold}
|
|
thresholdPeriod={thresholdPeriod}
|
|
thresholdType={thresholdType}
|
|
comparisonType={comparisonType}
|
|
currentProject={project.slug}
|
|
organization={organization}
|
|
availableActions={this.state.availableActions}
|
|
onChange={this.handleChangeTriggers}
|
|
onThresholdTypeChange={this.handleThresholdTypeChange}
|
|
onThresholdPeriodChange={this.handleThresholdPeriodChange}
|
|
onResolveThresholdChange={this.handleResolveThresholdChange}
|
|
/>
|
|
);
|
|
|
|
const ruleNameOwnerForm = (disabled: boolean) => (
|
|
<RuleNameOwnerForm disabled={disabled} project={project} />
|
|
);
|
|
|
|
const thresholdTypeForm = (disabled: boolean) => (
|
|
<ThresholdTypeForm
|
|
comparisonType={comparisonType}
|
|
dataset={dataset}
|
|
disabled={disabled}
|
|
onComparisonDeltaChange={value =>
|
|
this.handleFieldChange('comparisonDelta', value)
|
|
}
|
|
onComparisonTypeChange={this.handleComparisonTypeChange}
|
|
organization={organization}
|
|
comparisonDelta={comparisonDelta}
|
|
/>
|
|
);
|
|
|
|
const hasAlertWrite = hasEveryAccess(['alerts:write'], {organization, project});
|
|
const formDisabled = loading || !hasAlertWrite;
|
|
const submitDisabled = formDisabled || !this.state.isQueryValid;
|
|
|
|
const showErrorMigrationWarning =
|
|
!!ruleId &&
|
|
isMigration &&
|
|
hasIgnoreArchivedFeatureFlag(organization) &&
|
|
ruleNeedsErrorMigration(rule);
|
|
|
|
return (
|
|
<Main fullWidth>
|
|
<PermissionAlert access={['alerts:write']} project={project} />
|
|
|
|
{eventView && (
|
|
<IncompatibleAlertQuery orgSlug={organization.slug} eventView={eventView} />
|
|
)}
|
|
<Form
|
|
model={this.form}
|
|
apiMethod={ruleId ? 'PUT' : 'POST'}
|
|
apiEndpoint={`/organizations/${organization.slug}/alert-rules/${
|
|
ruleId ? `${ruleId}/` : ''
|
|
}`}
|
|
submitDisabled={submitDisabled}
|
|
initialData={{
|
|
name,
|
|
dataset,
|
|
eventTypes,
|
|
aggregate,
|
|
query,
|
|
timeWindow: rule.timeWindow,
|
|
environment: rule.environment || null,
|
|
owner: rule.owner,
|
|
projectId: project.id,
|
|
alertType,
|
|
}}
|
|
saveOnBlur={false}
|
|
onSubmit={this.handleSubmit}
|
|
onSubmitSuccess={onSubmitSuccess}
|
|
onCancel={this.handleCancel}
|
|
onFieldChange={this.handleFieldChange}
|
|
extraButton={
|
|
rule.id ? (
|
|
<Confirm
|
|
disabled={formDisabled}
|
|
message={t(
|
|
'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',
|
|
rule.name
|
|
)}
|
|
header={<h5>{t('Delete Alert Rule?')}</h5>}
|
|
priority="danger"
|
|
confirmText={t('Delete Rule')}
|
|
onConfirm={this.handleDeleteRule}
|
|
>
|
|
<Button priority="danger">{t('Delete Rule')}</Button>
|
|
</Confirm>
|
|
) : null
|
|
}
|
|
submitLabel={
|
|
isMigration && !triggersHaveChanged ? t('Looks good to me!') : t('Save Rule')
|
|
}
|
|
>
|
|
<List symbol="colored-numeric">
|
|
<RuleConditionsForm
|
|
project={project}
|
|
aggregate={aggregate}
|
|
organization={organization}
|
|
isTransactionMigration={isMigration && !showErrorMigrationWarning}
|
|
isErrorMigration={showErrorMigrationWarning}
|
|
router={router}
|
|
disabled={formDisabled}
|
|
thresholdChart={wizardBuilderChart}
|
|
onFilterSearch={this.handleFilterUpdate}
|
|
allowChangeEventTypes={
|
|
hasDDMFeature(organization)
|
|
? dataset === Dataset.ERRORS
|
|
: dataset === Dataset.ERRORS || alertType === 'custom_transactions'
|
|
}
|
|
alertType={alertType}
|
|
dataset={dataset}
|
|
timeWindow={timeWindow}
|
|
comparisonType={comparisonType}
|
|
comparisonDelta={comparisonDelta}
|
|
onComparisonDeltaChange={value =>
|
|
this.handleFieldChange('comparisonDelta', value)
|
|
}
|
|
onTimeWindowChange={value => this.handleFieldChange('timeWindow', value)}
|
|
disableProjectSelector={disableProjectSelector}
|
|
isExtrapolatedChartData={isExtrapolatedChartData}
|
|
/>
|
|
<AlertListItem>{t('Set thresholds')}</AlertListItem>
|
|
{thresholdTypeForm(formDisabled)}
|
|
{showErrorMigrationWarning && (
|
|
<Alert type="warning" showIcon>
|
|
{tct(
|
|
"We've added [code:is:unresolved] to your events filter; please make sure the current thresholds are still valid as this alert is now filtering out resolved and archived errors.",
|
|
{
|
|
code: <code />,
|
|
}
|
|
)}
|
|
</Alert>
|
|
)}
|
|
{triggerForm(formDisabled)}
|
|
{ruleNameOwnerForm(formDisabled)}
|
|
</List>
|
|
</Form>
|
|
</Main>
|
|
);
|
|
}
|
|
}
|
|
|
|
const Main = styled(Layout.Main)`
|
|
max-width: 1000px;
|
|
`;
|
|
|
|
const StyledListItem = styled(ListItem)`
|
|
margin: ${space(2)} 0 ${space(1)} 0;
|
|
font-size: ${p => p.theme.fontSizeExtraLarge};
|
|
`;
|
|
|
|
const AlertListItem = styled(StyledListItem)`
|
|
margin-top: 0;
|
|
`;
|
|
|
|
const ChartHeader = styled('div')`
|
|
padding: ${space(2)} ${space(3)} 0 ${space(3)};
|
|
margin-bottom: -${space(1.5)};
|
|
`;
|
|
|
|
const AlertName = styled(HeaderTitleLegend)`
|
|
position: relative;
|
|
`;
|
|
|
|
const AlertInfo = styled('div')`
|
|
font-size: ${p => p.theme.fontSizeSmall};
|
|
font-family: ${p => p.theme.text.family};
|
|
font-weight: normal;
|
|
color: ${p => p.theme.textColor};
|
|
`;
|
|
|
|
const StyledCircleIndicator = styled(CircleIndicator)`
|
|
background: ${p => p.theme.formText};
|
|
height: ${space(1)};
|
|
margin-right: ${space(0.5)};
|
|
`;
|
|
|
|
const Aggregate = styled('span')`
|
|
margin-right: ${space(1)};
|
|
`;
|
|
|
|
export default withProjects(RuleFormContainer);
|