ruleForm.tsx 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  1. import {ReactNode} from 'react';
  2. import {PlainRoute, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {
  5. addErrorMessage,
  6. addSuccessMessage,
  7. clearIndicators,
  8. Indicator,
  9. } from 'sentry/actionCreators/indicator';
  10. import {fetchOrganizationTags} from 'sentry/actionCreators/tags';
  11. import {hasEveryAccess} from 'sentry/components/acl/access';
  12. import {Button} from 'sentry/components/button';
  13. import {HeaderTitleLegend} from 'sentry/components/charts/styles';
  14. import CircleIndicator from 'sentry/components/circleIndicator';
  15. import Confirm from 'sentry/components/confirm';
  16. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  17. import Form, {FormProps} from 'sentry/components/forms/form';
  18. import FormModel from 'sentry/components/forms/model';
  19. import * as Layout from 'sentry/components/layouts/thirds';
  20. import List from 'sentry/components/list';
  21. import ListItem from 'sentry/components/list/listItem';
  22. import {t} from 'sentry/locale';
  23. import IndicatorStore from 'sentry/stores/indicatorStore';
  24. import {space} from 'sentry/styles/space';
  25. import {EventsStats, MultiSeriesEventsStats, Organization, Project} from 'sentry/types';
  26. import {defined} from 'sentry/utils';
  27. import {metric, trackAnalytics} from 'sentry/utils/analytics';
  28. import type EventView from 'sentry/utils/discover/eventView';
  29. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  30. import withProjects from 'sentry/utils/withProjects';
  31. import {IncompatibleAlertQuery} from 'sentry/views/alerts/rules/metric/incompatibleAlertQuery';
  32. import RuleNameOwnerForm from 'sentry/views/alerts/rules/metric/ruleNameOwnerForm';
  33. import ThresholdTypeForm from 'sentry/views/alerts/rules/metric/thresholdTypeForm';
  34. import Triggers from 'sentry/views/alerts/rules/metric/triggers';
  35. import TriggersChart from 'sentry/views/alerts/rules/metric/triggers/chart';
  36. import {getEventTypeFilter} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
  37. import hasThresholdValue from 'sentry/views/alerts/rules/metric/utils/hasThresholdValue';
  38. import {
  39. hasNonStandardMetricSearchFilters,
  40. isOnDemandMetricAlert,
  41. isValidOnDemandMetricAlert,
  42. } from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
  43. import {AlertRuleType} from 'sentry/views/alerts/types';
  44. import {
  45. AlertWizardAlertNames,
  46. DatasetMEPAlertQueryTypes,
  47. MetricAlertType,
  48. } from 'sentry/views/alerts/wizard/options';
  49. import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
  50. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  51. import {isCrashFreeAlert} from './utils/isCrashFreeAlert';
  52. import {addOrUpdateRule} from './actions';
  53. import {
  54. createDefaultTrigger,
  55. DEFAULT_CHANGE_COMP_DELTA,
  56. DEFAULT_CHANGE_TIME_WINDOW,
  57. DEFAULT_COUNT_TIME_WINDOW,
  58. } from './constants';
  59. import RuleConditionsForm from './ruleConditionsForm';
  60. import {
  61. AlertRuleComparisonType,
  62. AlertRuleThresholdType,
  63. AlertRuleTriggerType,
  64. Dataset,
  65. EventTypes,
  66. MetricActionTemplate,
  67. MetricRule,
  68. Trigger,
  69. UnsavedMetricRule,
  70. } from './types';
  71. const POLLING_MAX_TIME_LIMIT = 3 * 60000;
  72. type RuleTaskResponse = {
  73. status: 'pending' | 'failed' | 'success';
  74. alertRule?: MetricRule;
  75. error?: string;
  76. };
  77. type Props = {
  78. organization: Organization;
  79. project: Project;
  80. projects: Project[];
  81. routes: PlainRoute[];
  82. rule: MetricRule;
  83. userTeamIds: string[];
  84. disableProjectSelector?: boolean;
  85. eventView?: EventView;
  86. isCustomMetric?: boolean;
  87. isDuplicateRule?: boolean;
  88. ruleId?: string;
  89. sessionId?: string;
  90. } & RouteComponentProps<{projectId?: string; ruleId?: string}, {}> & {
  91. onSubmitSuccess?: FormProps['onSubmitSuccess'];
  92. } & DeprecatedAsyncComponent['props'];
  93. type State = {
  94. aggregate: string;
  95. alertType: MetricAlertType;
  96. // `null` means loading
  97. availableActions: MetricActionTemplate[] | null;
  98. comparisonType: AlertRuleComparisonType;
  99. // Rule conditions form inputs
  100. // Needed for TriggersChart
  101. dataset: Dataset;
  102. environment: string | null;
  103. eventTypes: EventTypes[];
  104. isQueryValid: boolean;
  105. project: Project;
  106. query: string;
  107. resolveThreshold: UnsavedMetricRule['resolveThreshold'];
  108. thresholdPeriod: UnsavedMetricRule['thresholdPeriod'];
  109. thresholdType: UnsavedMetricRule['thresholdType'];
  110. timeWindow: number;
  111. triggerErrors: Map<number, {[fieldName: string]: string}>;
  112. triggers: Trigger[];
  113. comparisonDelta?: number;
  114. uuid?: string;
  115. } & DeprecatedAsyncComponent['state'];
  116. const isEmpty = (str: unknown): boolean => str === '' || !defined(str);
  117. function determineAlertDataset(
  118. org: Organization,
  119. selectedDataset: Dataset,
  120. query: string
  121. ) {
  122. if (!org.features.includes('on-demand-metrics-extraction')) {
  123. return selectedDataset;
  124. }
  125. if (
  126. hasNonStandardMetricSearchFilters(query) &&
  127. selectedDataset === Dataset.TRANSACTIONS
  128. ) {
  129. // for on-demand metrics extraction we want to override the dataset and use performance metrics instead
  130. return Dataset.GENERIC_METRICS;
  131. }
  132. return selectedDataset;
  133. }
  134. class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
  135. form = new FormModel();
  136. pollingTimeout: number | undefined = undefined;
  137. get isDuplicateRule(): boolean {
  138. return Boolean(this.props.isDuplicateRule);
  139. }
  140. get chartQuery(): string {
  141. const {query, eventTypes, dataset} = this.state;
  142. const eventTypeFilter = getEventTypeFilter(this.state.dataset, eventTypes);
  143. const queryWithTypeFilter = `${query} ${eventTypeFilter}`.trim();
  144. return isCrashFreeAlert(dataset) ? query : queryWithTypeFilter;
  145. }
  146. componentDidMount() {
  147. super.componentDidMount();
  148. const {organization} = this.props;
  149. const {project} = this.state;
  150. // SearchBar gets its tags from Reflux.
  151. fetchOrganizationTags(this.api, organization.slug, [project.id]);
  152. }
  153. componentWillUnmount() {
  154. window.clearTimeout(this.pollingTimeout);
  155. }
  156. getDefaultState(): State {
  157. const {rule, location} = this.props;
  158. const triggersClone = [...rule.triggers];
  159. const {
  160. aggregate: _aggregate,
  161. eventTypes: _eventTypes,
  162. dataset: _dataset,
  163. name,
  164. } = location?.query ?? {};
  165. const eventTypes = typeof _eventTypes === 'string' ? [_eventTypes] : _eventTypes;
  166. // Warning trigger is removed if it is blank when saving
  167. if (triggersClone.length !== 2) {
  168. triggersClone.push(createDefaultTrigger(AlertRuleTriggerType.WARNING));
  169. }
  170. const aggregate = _aggregate ?? rule.aggregate;
  171. const dataset = _dataset ?? rule.dataset;
  172. return {
  173. ...super.getDefaultState(),
  174. name: name ?? rule.name ?? '',
  175. aggregate,
  176. dataset,
  177. eventTypes: eventTypes ?? rule.eventTypes ?? [],
  178. query: rule.query ?? '',
  179. isQueryValid: true, // Assume valid until input is changed
  180. timeWindow: rule.timeWindow,
  181. environment: rule.environment || null,
  182. triggerErrors: new Map(),
  183. availableActions: null,
  184. triggers: triggersClone,
  185. resolveThreshold: rule.resolveThreshold,
  186. thresholdType: rule.thresholdType,
  187. thresholdPeriod: rule.thresholdPeriod ?? 1,
  188. comparisonDelta: rule.comparisonDelta ?? undefined,
  189. comparisonType: rule.comparisonDelta
  190. ? AlertRuleComparisonType.CHANGE
  191. : AlertRuleComparisonType.COUNT,
  192. project: this.props.project,
  193. owner: rule.owner,
  194. alertType: getAlertTypeFromAggregateDataset({aggregate, dataset}),
  195. };
  196. }
  197. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  198. const {organization} = this.props;
  199. // TODO(incidents): This is temporary until new API endpoints
  200. // We should be able to just fetch the rule if rule.id exists
  201. return [
  202. [
  203. 'availableActions',
  204. `/organizations/${organization.slug}/alert-rules/available-actions/`,
  205. ],
  206. ];
  207. }
  208. goBack() {
  209. const {router} = this.props;
  210. const {organization} = this.props;
  211. router.push(normalizeUrl(`/organizations/${organization.slug}/alerts/rules/`));
  212. }
  213. resetPollingState = (loadingSlackIndicator: Indicator) => {
  214. IndicatorStore.remove(loadingSlackIndicator);
  215. this.setState({loading: false, uuid: undefined});
  216. };
  217. fetchStatus(model: FormModel) {
  218. const loadingSlackIndicator = IndicatorStore.addMessage(
  219. t('Looking for your slack channel (this can take a while)'),
  220. 'loading'
  221. );
  222. // pollHandler calls itself until it gets either a success
  223. // or failed status but we don't want to poll forever so we pass
  224. // in a hard stop time of 3 minutes before we bail.
  225. const quitTime = Date.now() + POLLING_MAX_TIME_LIMIT;
  226. window.clearTimeout(this.pollingTimeout);
  227. this.pollingTimeout = window.setTimeout(() => {
  228. this.pollHandler(model, quitTime, loadingSlackIndicator);
  229. }, 1000);
  230. }
  231. pollHandler = async (
  232. model: FormModel,
  233. quitTime: number,
  234. loadingSlackIndicator: Indicator
  235. ) => {
  236. if (Date.now() > quitTime) {
  237. addErrorMessage(t('Looking for that channel took too long :('));
  238. this.resetPollingState(loadingSlackIndicator);
  239. return;
  240. }
  241. const {
  242. organization,
  243. onSubmitSuccess,
  244. params: {ruleId},
  245. } = this.props;
  246. const {uuid, project} = this.state;
  247. try {
  248. const response: RuleTaskResponse = await this.api.requestPromise(
  249. `/projects/${organization.slug}/${project.slug}/alert-rule-task/${uuid}/`
  250. );
  251. const {status, alertRule, error} = response;
  252. if (status === 'pending') {
  253. window.clearTimeout(this.pollingTimeout);
  254. this.pollingTimeout = window.setTimeout(() => {
  255. this.pollHandler(model, quitTime, loadingSlackIndicator);
  256. }, 1000);
  257. return;
  258. }
  259. this.resetPollingState(loadingSlackIndicator);
  260. if (status === 'failed') {
  261. this.handleRuleSaveFailure(error);
  262. }
  263. if (alertRule) {
  264. addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
  265. if (onSubmitSuccess) {
  266. onSubmitSuccess(alertRule, model);
  267. }
  268. }
  269. } catch {
  270. this.handleRuleSaveFailure(t('An error occurred'));
  271. this.resetPollingState(loadingSlackIndicator);
  272. }
  273. };
  274. /**
  275. * Checks to see if threshold is valid given target value, and state of
  276. * inverted threshold as well as the *other* threshold
  277. *
  278. * @param type The threshold type to be updated
  279. * @param value The new threshold value
  280. */
  281. isValidTrigger = (
  282. triggerIndex: number,
  283. trigger: Trigger,
  284. errors,
  285. resolveThreshold: number | '' | null
  286. ): boolean => {
  287. const {alertThreshold} = trigger;
  288. const {thresholdType} = this.state;
  289. // If value and/or other value is empty
  290. // then there are no checks to perform against
  291. if (!hasThresholdValue(alertThreshold) || !hasThresholdValue(resolveThreshold)) {
  292. return true;
  293. }
  294. // If this is alert threshold and not inverted, it can't be below resolve
  295. // If this is alert threshold and inverted, it can't be above resolve
  296. // If this is resolve threshold and not inverted, it can't be above resolve
  297. // If this is resolve threshold and inverted, it can't be below resolve
  298. // Since we're comparing non-inclusive thresholds here (>, <), we need
  299. // to modify the values when we compare. An example of why:
  300. // Alert > 0, resolve < 1. This means that we want to alert on values
  301. // of 1 or more, and resolve on values of 0 or less. This is valid, but
  302. // without modifying the values, this boundary case will fail.
  303. const isValid =
  304. thresholdType === AlertRuleThresholdType.BELOW
  305. ? alertThreshold - 1 < resolveThreshold + 1
  306. : alertThreshold + 1 > resolveThreshold - 1;
  307. const otherErrors = errors.get(triggerIndex) || {};
  308. if (isValid) {
  309. return true;
  310. }
  311. // Not valid... let's figure out an error message
  312. const isBelow = thresholdType === AlertRuleThresholdType.BELOW;
  313. let errorMessage = '';
  314. if (typeof resolveThreshold !== 'number') {
  315. errorMessage = isBelow
  316. ? t('Resolution threshold must be greater than alert')
  317. : t('Resolution threshold must be less than alert');
  318. } else {
  319. errorMessage = isBelow
  320. ? t('Alert threshold must be less than resolution')
  321. : t('Alert threshold must be greater than resolution');
  322. }
  323. errors.set(triggerIndex, {
  324. ...otherErrors,
  325. alertThreshold: errorMessage,
  326. });
  327. return false;
  328. };
  329. validateFieldInTrigger({errors, triggerIndex, field, message, isValid}) {
  330. // If valid, reset error for fieldName
  331. if (isValid()) {
  332. const {[field]: _validatedField, ...otherErrors} = errors.get(triggerIndex) || {};
  333. if (Object.keys(otherErrors).length > 0) {
  334. errors.set(triggerIndex, otherErrors);
  335. } else {
  336. errors.delete(triggerIndex);
  337. }
  338. return errors;
  339. }
  340. if (!errors.has(triggerIndex)) {
  341. errors.set(triggerIndex, {});
  342. }
  343. const currentErrors = errors.get(triggerIndex);
  344. errors.set(triggerIndex, {
  345. ...currentErrors,
  346. [field]: message,
  347. });
  348. return errors;
  349. }
  350. /**
  351. * Validate triggers
  352. *
  353. * @return Returns true if triggers are valid
  354. */
  355. validateTriggers(
  356. triggers = this.state.triggers,
  357. thresholdType = this.state.thresholdType,
  358. resolveThreshold = this.state.resolveThreshold,
  359. changedTriggerIndex?: number
  360. ) {
  361. const {comparisonType} = this.state;
  362. const triggerErrors = new Map();
  363. const requiredFields = ['label', 'alertThreshold'];
  364. triggers.forEach((trigger, triggerIndex) => {
  365. requiredFields.forEach(field => {
  366. // check required fields
  367. this.validateFieldInTrigger({
  368. errors: triggerErrors,
  369. triggerIndex,
  370. isValid: (): boolean => {
  371. if (trigger.label === AlertRuleTriggerType.CRITICAL) {
  372. return !isEmpty(trigger[field]);
  373. }
  374. // If warning trigger has actions, it must have a value
  375. return trigger.actions.length === 0 || !isEmpty(trigger[field]);
  376. },
  377. field,
  378. message: t('Field is required'),
  379. });
  380. });
  381. // Check thresholds
  382. this.isValidTrigger(
  383. changedTriggerIndex ?? triggerIndex,
  384. trigger,
  385. triggerErrors,
  386. resolveThreshold
  387. );
  388. });
  389. // If we have 2 triggers, we need to make sure that the critical and warning
  390. // alert thresholds are valid (e.g. if critical is above x, warning must be less than x)
  391. const criticalTriggerIndex = triggers.findIndex(
  392. ({label}) => label === AlertRuleTriggerType.CRITICAL
  393. );
  394. const warningTriggerIndex = criticalTriggerIndex ^ 1;
  395. const criticalTrigger = triggers[criticalTriggerIndex];
  396. const warningTrigger = triggers[warningTriggerIndex];
  397. const isEmptyWarningThreshold = isEmpty(warningTrigger.alertThreshold);
  398. const warningThreshold = warningTrigger.alertThreshold ?? 0;
  399. const criticalThreshold = criticalTrigger.alertThreshold ?? 0;
  400. const hasError =
  401. thresholdType === AlertRuleThresholdType.ABOVE ||
  402. comparisonType === AlertRuleComparisonType.CHANGE
  403. ? warningThreshold > criticalThreshold
  404. : warningThreshold < criticalThreshold;
  405. if (hasError && !isEmptyWarningThreshold) {
  406. [criticalTriggerIndex, warningTriggerIndex].forEach(index => {
  407. const otherErrors = triggerErrors.get(index) ?? {};
  408. triggerErrors.set(index, {
  409. ...otherErrors,
  410. alertThreshold:
  411. thresholdType === AlertRuleThresholdType.ABOVE ||
  412. comparisonType === AlertRuleComparisonType.CHANGE
  413. ? t('Warning threshold must be less than critical threshold')
  414. : t('Warning threshold must be greater than critical threshold'),
  415. });
  416. });
  417. }
  418. return triggerErrors;
  419. }
  420. handleFieldChange = (name: string, value: unknown) => {
  421. const {projects} = this.props;
  422. if (name === 'alertType') {
  423. this.setState({
  424. alertType: value as MetricAlertType,
  425. });
  426. return;
  427. }
  428. if (
  429. [
  430. 'aggregate',
  431. 'dataset',
  432. 'eventTypes',
  433. 'timeWindow',
  434. 'environment',
  435. 'comparisonDelta',
  436. 'projectId',
  437. 'alertType',
  438. ].includes(name)
  439. ) {
  440. this.setState(({project: _project, aggregate, dataset, alertType}) => {
  441. const newAlertType = getAlertTypeFromAggregateDataset({aggregate, dataset});
  442. return {
  443. [name]: value,
  444. project:
  445. name === 'projectId' ? projects.find(({id}) => id === value) : _project,
  446. alertType: alertType !== newAlertType ? 'custom' : alertType,
  447. };
  448. });
  449. }
  450. };
  451. // We handle the filter update outside of the fieldChange handler since we
  452. // don't want to update the filter on every input change, just on blurs and
  453. // searches.
  454. handleFilterUpdate = (query: string, isQueryValid: boolean) => {
  455. const {organization, sessionId} = this.props;
  456. trackAnalytics('alert_builder.filter', {
  457. organization,
  458. session_id: sessionId,
  459. query,
  460. });
  461. this.setState({query, isQueryValid});
  462. };
  463. validateOnDemandMetricAlert() {
  464. return isValidOnDemandMetricAlert(
  465. this.state.dataset,
  466. this.state.aggregate,
  467. this.state.query
  468. );
  469. }
  470. handleSubmit = async (
  471. _data: Partial<MetricRule>,
  472. _onSubmitSuccess,
  473. _onSubmitError,
  474. _e,
  475. model: FormModel
  476. ) => {
  477. // This validates all fields *except* for Triggers
  478. const validRule = model.validateForm();
  479. // Validate Triggers
  480. const triggerErrors = this.validateTriggers();
  481. const validTriggers = Array.from(triggerErrors).length === 0;
  482. const validOnDemandAlert = this.validateOnDemandMetricAlert();
  483. if (!validTriggers) {
  484. this.setState(state => ({
  485. triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
  486. }));
  487. }
  488. if (!validRule || !validTriggers) {
  489. const missingFields = [
  490. !validRule && t('name'),
  491. !validRule && !validTriggers && t('and'),
  492. !validTriggers && t('critical threshold'),
  493. ].filter(x => x);
  494. addErrorMessage(t('Alert not valid: missing %s', missingFields.join(' ')));
  495. return;
  496. }
  497. if (!validOnDemandAlert) {
  498. addErrorMessage(
  499. t('%s is not supported for on-demand metric alerts', this.state.aggregate)
  500. );
  501. return;
  502. }
  503. const {
  504. organization,
  505. rule,
  506. onSubmitSuccess,
  507. location,
  508. sessionId,
  509. params: {ruleId},
  510. } = this.props;
  511. const {
  512. project,
  513. aggregate,
  514. resolveThreshold,
  515. triggers,
  516. thresholdType,
  517. thresholdPeriod,
  518. comparisonDelta,
  519. uuid,
  520. timeWindow,
  521. eventTypes,
  522. } = this.state;
  523. // Remove empty warning trigger
  524. const sanitizedTriggers = triggers.filter(
  525. trigger =>
  526. trigger.label !== AlertRuleTriggerType.WARNING || !isEmpty(trigger.alertThreshold)
  527. );
  528. // form model has all form state data, however we use local state to keep
  529. // track of the list of triggers (and actions within triggers)
  530. const loadingIndicator = IndicatorStore.addMessage(
  531. t('Saving your alert rule, hold on...'),
  532. 'loading'
  533. );
  534. try {
  535. const transaction = metric.startTransaction({name: 'saveAlertRule'});
  536. transaction.setTag('type', AlertRuleType.METRIC);
  537. transaction.setTag('operation', !rule.id ? 'create' : 'edit');
  538. for (const trigger of sanitizedTriggers) {
  539. for (const action of trigger.actions) {
  540. if (action.type === 'slack') {
  541. transaction.setTag(action.type, true);
  542. }
  543. }
  544. }
  545. transaction.setData('actions', sanitizedTriggers);
  546. const hasMetricDataset = organization.features.includes('mep-rollout-flag');
  547. const dataset = determineAlertDataset(
  548. organization,
  549. this.state.dataset,
  550. model.getTransformedData().query
  551. );
  552. this.setState({loading: true});
  553. const [data, , resp] = await addOrUpdateRule(
  554. this.api,
  555. organization.slug,
  556. project.slug,
  557. {
  558. ...rule,
  559. ...model.getTransformedData(),
  560. triggers: sanitizedTriggers,
  561. resolveThreshold: isEmpty(resolveThreshold) ? null : resolveThreshold,
  562. thresholdType,
  563. thresholdPeriod,
  564. comparisonDelta: comparisonDelta ?? null,
  565. timeWindow,
  566. aggregate,
  567. ...(hasMetricDataset ? {queryType: DatasetMEPAlertQueryTypes[dataset]} : {}),
  568. // Remove eventTypes as it is no longer required for crash free
  569. eventTypes: isCrashFreeAlert(rule.dataset) ? undefined : eventTypes,
  570. dataset,
  571. },
  572. {
  573. duplicateRule: this.isDuplicateRule ? 'true' : 'false',
  574. wizardV3: 'true',
  575. referrer: location?.query?.referrer,
  576. sessionId,
  577. }
  578. );
  579. // if we get a 202 back it means that we have an async task
  580. // running to lookup and verify the channel id for Slack.
  581. if (resp?.status === 202) {
  582. // if we have a uuid in state, no need to start a new polling cycle
  583. if (!uuid) {
  584. this.setState({loading: true, uuid: data.uuid});
  585. this.fetchStatus(model);
  586. }
  587. } else {
  588. IndicatorStore.remove(loadingIndicator);
  589. this.setState({loading: false});
  590. addSuccessMessage(ruleId ? t('Updated alert rule') : t('Created alert rule'));
  591. if (onSubmitSuccess) {
  592. onSubmitSuccess(data, model);
  593. }
  594. }
  595. } catch (err) {
  596. IndicatorStore.remove(loadingIndicator);
  597. this.setState({loading: false});
  598. const errors = err?.responseJSON
  599. ? Array.isArray(err?.responseJSON)
  600. ? err?.responseJSON
  601. : Object.values(err?.responseJSON)
  602. : [];
  603. const apiErrors = errors.length > 0 ? `: ${errors.join(', ')}` : '';
  604. this.handleRuleSaveFailure(t('Unable to save alert%s', apiErrors));
  605. }
  606. };
  607. /**
  608. * Callback for when triggers change
  609. *
  610. * Re-validate triggers on every change and reset indicators when no errors
  611. */
  612. handleChangeTriggers = (triggers: Trigger[], triggerIndex?: number) => {
  613. this.setState(state => {
  614. let triggerErrors = state.triggerErrors;
  615. const newTriggerErrors = this.validateTriggers(
  616. triggers,
  617. state.thresholdType,
  618. state.resolveThreshold,
  619. triggerIndex
  620. );
  621. triggerErrors = newTriggerErrors;
  622. if (Array.from(newTriggerErrors).length === 0) {
  623. clearIndicators();
  624. }
  625. return {triggers, triggerErrors};
  626. });
  627. };
  628. handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
  629. const {triggers} = this.state;
  630. const triggerErrors = this.validateTriggers(triggers, thresholdType);
  631. this.setState(state => ({
  632. thresholdType,
  633. triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
  634. }));
  635. };
  636. handleThresholdPeriodChange = (value: number) => {
  637. this.setState({thresholdPeriod: value});
  638. };
  639. handleResolveThresholdChange = (
  640. resolveThreshold: UnsavedMetricRule['resolveThreshold']
  641. ) => {
  642. this.setState(state => {
  643. const triggerErrors = this.validateTriggers(
  644. state.triggers,
  645. state.thresholdType,
  646. resolveThreshold
  647. );
  648. if (Array.from(triggerErrors).length === 0) {
  649. clearIndicators();
  650. }
  651. return {resolveThreshold, triggerErrors};
  652. });
  653. };
  654. handleComparisonTypeChange = (value: AlertRuleComparisonType) => {
  655. const comparisonDelta =
  656. value === AlertRuleComparisonType.COUNT
  657. ? undefined
  658. : this.state.comparisonDelta ?? DEFAULT_CHANGE_COMP_DELTA;
  659. const timeWindow = this.state.comparisonDelta
  660. ? DEFAULT_COUNT_TIME_WINDOW
  661. : DEFAULT_CHANGE_TIME_WINDOW;
  662. this.setState({comparisonType: value, comparisonDelta, timeWindow});
  663. };
  664. handleDeleteRule = async () => {
  665. const {organization, params} = this.props;
  666. const {projectId, ruleId} = params;
  667. try {
  668. await this.api.requestPromise(
  669. `/projects/${organization.slug}/${projectId}/alert-rules/${ruleId}/`,
  670. {
  671. method: 'DELETE',
  672. }
  673. );
  674. this.goBack();
  675. } catch (_err) {
  676. addErrorMessage(t('Error deleting rule'));
  677. }
  678. };
  679. handleRuleSaveFailure = (msg: ReactNode) => {
  680. addErrorMessage(msg);
  681. metric.endTransaction({name: 'saveAlertRule'});
  682. };
  683. handleCancel = () => {
  684. this.goBack();
  685. };
  686. handleMEPAlertDataset = (data: EventsStats | MultiSeriesEventsStats | null) => {
  687. const {isMetricsData} = data ?? {};
  688. const {organization} = this.props;
  689. if (
  690. isMetricsData === undefined ||
  691. !organization.features.includes('mep-rollout-flag')
  692. ) {
  693. return;
  694. }
  695. const {dataset} = this.state;
  696. if (isMetricsData && dataset === Dataset.TRANSACTIONS) {
  697. this.setState({dataset: Dataset.GENERIC_METRICS});
  698. }
  699. if (!isMetricsData && dataset === Dataset.GENERIC_METRICS) {
  700. this.setState({dataset: Dataset.TRANSACTIONS});
  701. }
  702. };
  703. renderLoading() {
  704. return this.renderBody();
  705. }
  706. renderBody() {
  707. const {
  708. organization,
  709. ruleId,
  710. rule,
  711. onSubmitSuccess,
  712. router,
  713. disableProjectSelector,
  714. eventView,
  715. location,
  716. } = this.props;
  717. const {
  718. name,
  719. query,
  720. project,
  721. timeWindow,
  722. triggers,
  723. aggregate,
  724. environment,
  725. thresholdType,
  726. thresholdPeriod,
  727. comparisonDelta,
  728. comparisonType,
  729. resolveThreshold,
  730. loading,
  731. eventTypes,
  732. dataset,
  733. alertType,
  734. isQueryValid,
  735. } = this.state;
  736. const chartProps = {
  737. organization,
  738. projects: [project],
  739. triggers,
  740. location,
  741. query: this.chartQuery,
  742. aggregate,
  743. dataset,
  744. newAlertOrQuery: !ruleId || query !== rule.query,
  745. handleMEPAlertDataset: this.handleMEPAlertDataset,
  746. timeWindow,
  747. environment,
  748. resolveThreshold,
  749. thresholdType,
  750. comparisonDelta,
  751. comparisonType,
  752. isQueryValid,
  753. };
  754. const wizardBuilderChart = (
  755. <TriggersChart
  756. {...chartProps}
  757. isOnDemandMetricAlert={isOnDemandMetricAlert(dataset, query)}
  758. header={
  759. <ChartHeader>
  760. <AlertName>{AlertWizardAlertNames[alertType]}</AlertName>
  761. {!isCrashFreeAlert(dataset) && (
  762. <AlertInfo>
  763. <StyledCircleIndicator size={8} />
  764. <Aggregate>{aggregate}</Aggregate>
  765. event.type:{eventTypes?.join(',')}
  766. </AlertInfo>
  767. )}
  768. </ChartHeader>
  769. }
  770. />
  771. );
  772. const triggerForm = (disabled: boolean) => (
  773. <Triggers
  774. disabled={disabled}
  775. projects={[project]}
  776. errors={this.state.triggerErrors}
  777. triggers={triggers}
  778. aggregate={aggregate}
  779. resolveThreshold={resolveThreshold}
  780. thresholdPeriod={thresholdPeriod}
  781. thresholdType={thresholdType}
  782. comparisonType={comparisonType}
  783. currentProject={project.slug}
  784. organization={organization}
  785. availableActions={this.state.availableActions}
  786. onChange={this.handleChangeTriggers}
  787. onThresholdTypeChange={this.handleThresholdTypeChange}
  788. onThresholdPeriodChange={this.handleThresholdPeriodChange}
  789. onResolveThresholdChange={this.handleResolveThresholdChange}
  790. />
  791. );
  792. const ruleNameOwnerForm = (disabled: boolean) => (
  793. <RuleNameOwnerForm disabled={disabled} project={project} />
  794. );
  795. const thresholdTypeForm = (disabled: boolean) => (
  796. <ThresholdTypeForm
  797. comparisonType={comparisonType}
  798. dataset={dataset}
  799. disabled={disabled}
  800. onComparisonDeltaChange={value =>
  801. this.handleFieldChange('comparisonDelta', value)
  802. }
  803. onComparisonTypeChange={this.handleComparisonTypeChange}
  804. organization={organization}
  805. comparisonDelta={comparisonDelta}
  806. />
  807. );
  808. const hasAlertWrite = hasEveryAccess(['alerts:write'], {organization, project});
  809. const formDisabled = loading || !hasAlertWrite;
  810. const submitDisabled = formDisabled || !this.state.isQueryValid;
  811. return (
  812. <Main fullWidth>
  813. <PermissionAlert access={['alerts:write']} project={project} />
  814. {eventView && (
  815. <IncompatibleAlertQuery orgSlug={organization.slug} eventView={eventView} />
  816. )}
  817. <Form
  818. model={this.form}
  819. apiMethod={ruleId ? 'PUT' : 'POST'}
  820. apiEndpoint={`/organizations/${organization.slug}/alert-rules/${
  821. ruleId ? `${ruleId}/` : ''
  822. }`}
  823. submitDisabled={submitDisabled}
  824. initialData={{
  825. name,
  826. dataset,
  827. eventTypes,
  828. aggregate,
  829. query,
  830. timeWindow: rule.timeWindow,
  831. environment: rule.environment || null,
  832. owner: rule.owner,
  833. projectId: project.id,
  834. alertType,
  835. }}
  836. saveOnBlur={false}
  837. onSubmit={this.handleSubmit}
  838. onSubmitSuccess={onSubmitSuccess}
  839. onCancel={this.handleCancel}
  840. onFieldChange={this.handleFieldChange}
  841. extraButton={
  842. rule.id ? (
  843. <Confirm
  844. disabled={formDisabled}
  845. message={t('Are you sure you want to delete this alert rule?')}
  846. header={t('Delete Alert Rule?')}
  847. priority="danger"
  848. confirmText={t('Delete Rule')}
  849. onConfirm={this.handleDeleteRule}
  850. >
  851. <Button priority="danger">{t('Delete Rule')}</Button>
  852. </Confirm>
  853. ) : null
  854. }
  855. submitLabel={t('Save Rule')}
  856. >
  857. <List symbol="colored-numeric">
  858. <RuleConditionsForm
  859. project={project}
  860. organization={organization}
  861. router={router}
  862. disabled={formDisabled}
  863. thresholdChart={wizardBuilderChart}
  864. onFilterSearch={this.handleFilterUpdate}
  865. allowChangeEventTypes={alertType === 'custom' || dataset === Dataset.ERRORS}
  866. alertType={alertType}
  867. dataset={dataset}
  868. timeWindow={timeWindow}
  869. comparisonType={comparisonType}
  870. comparisonDelta={comparisonDelta}
  871. onComparisonDeltaChange={value =>
  872. this.handleFieldChange('comparisonDelta', value)
  873. }
  874. onTimeWindowChange={value => this.handleFieldChange('timeWindow', value)}
  875. disableProjectSelector={disableProjectSelector}
  876. />
  877. <AlertListItem>{t('Set thresholds')}</AlertListItem>
  878. {thresholdTypeForm(formDisabled)}
  879. {triggerForm(formDisabled)}
  880. {ruleNameOwnerForm(formDisabled)}
  881. </List>
  882. </Form>
  883. </Main>
  884. );
  885. }
  886. }
  887. const Main = styled(Layout.Main)`
  888. padding: ${space(2)} ${space(4)};
  889. `;
  890. const StyledListItem = styled(ListItem)`
  891. margin: ${space(2)} 0 ${space(1)} 0;
  892. font-size: ${p => p.theme.fontSizeExtraLarge};
  893. `;
  894. const AlertListItem = styled(StyledListItem)`
  895. margin-top: 0;
  896. `;
  897. const ChartHeader = styled('div')`
  898. padding: ${space(2)} ${space(3)} 0 ${space(3)};
  899. margin-bottom: -${space(1.5)};
  900. `;
  901. const AlertName = styled(HeaderTitleLegend)`
  902. position: relative;
  903. `;
  904. const AlertInfo = styled('div')`
  905. font-size: ${p => p.theme.fontSizeSmall};
  906. font-family: ${p => p.theme.text.family};
  907. font-weight: normal;
  908. color: ${p => p.theme.textColor};
  909. `;
  910. const StyledCircleIndicator = styled(CircleIndicator)`
  911. background: ${p => p.theme.formText};
  912. height: ${space(1)};
  913. margin-right: ${space(0.5)};
  914. `;
  915. const Aggregate = styled('span')`
  916. margin-right: ${space(1)};
  917. `;
  918. export default withProjects(RuleFormContainer);