Browse Source

feat(alert): Create metric alerts on project creation (#36582)

* UI & MakeUnqueriedContext

* sending api request & patch teams

* Add experiment & use Promise.all

* rename events

* use the new checkbox component
Zhixing Zhang 2 years ago
parent
commit
6105e8bdef

+ 6 - 0
static/app/data/experimentConfig.tsx

@@ -37,6 +37,12 @@ export const experimentList = [
     parameter: 'exposed',
     assignments: [0, 1],
   },
+  {
+    key: 'MetricAlertOnProjectCreationExperiment',
+    type: ExperimentType.Organization,
+    parameter: 'exposed',
+    assignments: [0, 1],
+  },
 ] as const;
 
 export const experimentConfig = experimentList.reduce(

+ 2 - 0
static/app/utils/analytics/workflowAnalyticsEvents.tsx

@@ -52,6 +52,7 @@ export type TeamInsightsEventParameters = {
     session_id: string;
     wizard_v3: string;
   };
+  'project_creation_page.viewed': {};
   'team_insights.viewed': {};
 };
 
@@ -75,4 +76,5 @@ export const workflowEventMap: Record<TeamInsightsEventKey, string | null> = {
   'issue_details.viewed': 'Issue Details: Viewed',
   'new_alert_rule.viewed': 'New Alert Rule: Viewed',
   'team_insights.viewed': 'Team Insights: Viewed',
+  'project_creation_page.viewed': 'Project Create: Creation page viewed',
 };

+ 30 - 14
static/app/views/alerts/rules/metric/presets.tsx

@@ -41,6 +41,7 @@ export type Preset = {
     project: Project,
     organization: Organization
   ): Promise<PresetContext>;
+  makeUnqueriedContext(project: Project, organization: Organization): PresetContext;
   title: string;
 };
 
@@ -94,17 +95,14 @@ function makeTeamWarningAlert(threshold: number = 100) {
   };
 }
 
-export const PRESET_AGGREGATES: Preset[] = [
+export const PRESET_AGGREGATES: readonly Preset[] = [
   {
     id: 'p95-highest-volume',
     title: t('Slow transactions'),
     description: 'Get notified when important transactions are slower on average',
     Icon: IconGraph,
     alertType: 'trans_duration',
-    async makeContext(client, project, organization) {
-      const transaction = (
-        await getHighestVolumeTransaction(client, organization.slug, project.id)
-      )?.[0];
+    makeUnqueriedContext(project, _) {
       return {
         name: t('p95 Alert for %s', [project.slug]),
         aggregate: 'p95(transaction.duration)',
@@ -115,6 +113,14 @@ export const PRESET_AGGREGATES: Preset[] = [
         comparisonType: AlertRuleComparisonType.CHANGE,
         thresholdType: AlertRuleThresholdType.ABOVE,
         triggers: [makeTeamCriticalAlert(project), makeTeamWarningAlert()],
+      };
+    },
+    async makeContext(client, project, organization) {
+      const transaction = (
+        await getHighestVolumeTransaction(client, organization.slug, project.id)
+      )?.[0];
+      return {
+        ...this.makeUnqueriedContext(project, organization),
         query: 'transaction:' + transaction,
       };
     },
@@ -125,10 +131,7 @@ export const PRESET_AGGREGATES: Preset[] = [
     description: 'Send an alert when transaction throughput drops significantly',
     Icon: IconGraph,
     alertType: 'throughput',
-    async makeContext(client, project, organization) {
-      const transaction = (
-        await getHighestVolumeTransaction(client, organization.slug, project.id)
-      )?.[0];
+    makeUnqueriedContext(project, _) {
       return {
         name: t('Throughput Alert for %s', [project.slug]),
         aggregate: 'count()',
@@ -139,6 +142,14 @@ export const PRESET_AGGREGATES: Preset[] = [
         comparisonType: AlertRuleComparisonType.CHANGE,
         thresholdType: AlertRuleThresholdType.BELOW,
         triggers: [makeTeamCriticalAlert(project, 500), makeTeamWarningAlert(300)],
+      };
+    },
+    async makeContext(client, project, organization) {
+      const transaction = (
+        await getHighestVolumeTransaction(client, organization.slug, project.id)
+      )?.[0];
+      return {
+        ...this.makeUnqueriedContext(project, organization),
         query: 'transaction:' + transaction,
       };
     },
@@ -150,10 +161,7 @@ export const PRESET_AGGREGATES: Preset[] = [
       'Learn when the ratio of satisfactory, tolerable, and frustrated requests drop',
     Icon: IconGraph,
     alertType: 'apdex',
-    async makeContext(client, project, organization) {
-      const transaction = (
-        await getHighestVolumeTransaction(client, organization.slug, project.id)
-      )?.[0];
+    makeUnqueriedContext(project, _) {
       return {
         name: t('Apdex regression for %s', [project.slug]),
         aggregate: 'apdex(300)',
@@ -164,8 +172,16 @@ export const PRESET_AGGREGATES: Preset[] = [
         comparisonType: AlertRuleComparisonType.CHANGE,
         thresholdType: AlertRuleThresholdType.BELOW,
         triggers: [makeTeamCriticalAlert(project), makeTeamWarningAlert()],
+      };
+    },
+    async makeContext(client, project, organization) {
+      const transaction = (
+        await getHighestVolumeTransaction(client, organization.slug, project.id)
+      )?.[0];
+      return {
+        ...this.makeUnqueriedContext(project, organization),
         query: 'transaction:' + transaction,
       };
     },
   },
-];
+] as const;

+ 54 - 0
static/app/views/projectInstall/createProject.tsx

@@ -19,6 +19,7 @@ import {inputStyles} from 'sentry/styles/input';
 import space from 'sentry/styles/space';
 import {Organization, Project, Team} from 'sentry/types';
 import {trackAnalyticsEvent} from 'sentry/utils/analytics';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import getPlatformName from 'sentry/utils/getPlatformName';
 import slugify from 'sentry/utils/slugify';
 import withApi from 'sentry/utils/withApi';
@@ -26,6 +27,8 @@ import withOrganization from 'sentry/utils/withOrganization';
 import withTeams from 'sentry/utils/withTeams';
 import IssueAlertOptions from 'sentry/views/projectInstall/issueAlertOptions';
 
+import {PRESET_AGGREGATES} from '../alerts/rules/metric/presets';
+
 const getCategoryName = (category?: string) =>
   categoryList.find(({id}) => id === category)?.id;
 
@@ -84,6 +87,12 @@ class CreateProject extends Component<Props, State> {
     return getCategoryName(query.category);
   }
 
+  componentDidMount() {
+    trackAdvancedAnalyticsEvent('project_creation_page.viewed', {
+      organization: this.props.organization,
+    });
+  }
+
   renderProjectForm() {
     const {organization} = this.props;
     const {projectName, platform, team} = this.state;
@@ -176,6 +185,7 @@ class CreateProject extends Component<Props, State> {
       actionMatch,
       frequency,
       defaultRules,
+      metricAlertPresets,
     } = dataFragment || {};
 
     this.setState({inFlight: true});
@@ -215,6 +225,50 @@ class CreateProject extends Component<Props, State> {
         );
         ruleId = ruleData.id;
       }
+      if (
+        !!organization.experiments.MetricAlertOnProjectCreationExperiment &&
+        metricAlertPresets &&
+        metricAlertPresets.length > 0
+      ) {
+        const presets = PRESET_AGGREGATES.filter(aggregate =>
+          metricAlertPresets.includes(aggregate.id)
+        );
+        const teamObj = this.props.teams.find(aTeam => aTeam.slug === team);
+        await Promise.all([
+          presets.map(preset => {
+            const context = preset.makeUnqueriedContext(
+              {
+                ...projectData,
+                teams: teamObj ? [teamObj] : [],
+              },
+              organization
+            );
+
+            return api.requestPromise(
+              `/projects/${organization.slug}/${projectData.slug}/alert-rules/?referrer=create_project`,
+              {
+                method: 'POST',
+                data: {
+                  aggregate: context.aggregate,
+                  comparisonDelta: context.comparisonDelta,
+                  dataset: context.dataset,
+                  eventTypes: context.eventTypes,
+                  name: context.name,
+                  owner: null,
+                  projectId: projectData.id,
+                  projects: [projectData.slug],
+                  query: '',
+                  resolveThreshold: null,
+                  thresholdPeriod: 1,
+                  thresholdType: context.thresholdType,
+                  timeWindow: context.timeWindow,
+                  triggers: context.triggers,
+                },
+              }
+            );
+          }),
+        ]);
+      }
       this.trackIssueAlertOptionSelectedEvent(
         projectData,
         defaultRules,

+ 61 - 9
static/app/views/projectInstall/issueAlertOptions.tsx

@@ -1,4 +1,5 @@
 import {Fragment} from 'react';
+import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import isEqual from 'lodash/isEqual';
@@ -6,6 +7,7 @@ import isEqual from 'lodash/isEqual';
 import AsyncComponent from 'sentry/components/asyncComponent';
 import Input from 'sentry/components/forms/controls/input';
 import RadioGroup from 'sentry/components/forms/controls/radioGroup';
+import MultipleCheckboxField from 'sentry/components/forms/MultipleCheckboxField';
 import SelectControl from 'sentry/components/forms/selectControl';
 import PageHeading from 'sentry/components/pageHeading';
 import {t} from 'sentry/locale';
@@ -13,6 +15,8 @@ import space from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
 import withOrganization from 'sentry/utils/withOrganization';
 
+import {PRESET_AGGREGATES} from '../alerts/rules/metric/presets';
+
 enum MetricValues {
   ERRORS,
   USERS,
@@ -51,6 +55,8 @@ type State = AsyncComponent['state'] & {
   interval: string;
   intervalChoices: [string, string][] | undefined;
   metric: MetricValues;
+  metricAlertPresets: Set<string>;
+
   threshold: string;
 };
 
@@ -60,6 +66,7 @@ type RequestDataFragment = {
   conditions: {id: string; interval: string; value: string}[] | undefined;
   defaultRules: boolean;
   frequency: number;
+  metricAlertPresets: string[];
   name: string;
   shouldCreateCustomRule: boolean;
 };
@@ -111,6 +118,7 @@ class IssueAlertOptions extends AsyncComponent<Props, State> {
       metric: MetricValues.ERRORS,
       interval: '',
       threshold: '',
+      metricAlertPresets: new Set(),
     };
   }
 
@@ -224,6 +232,7 @@ class IssueAlertOptions extends AsyncComponent<Props, State> {
       actions: [{id: NOTIFY_EVENT_ACTION}],
       actionMatch: 'all',
       frequency: 5,
+      metricAlertPresets: Array.from(this.state.metricAlertPresets),
     };
   }
 
@@ -286,17 +295,47 @@ class IssueAlertOptions extends AsyncComponent<Props, State> {
     const issueAlertOptionsChoices = this.getIssueAlertsChoices(
       this.state.conditions?.length > 0
     );
+    const showMetricAlertSelections =
+      !!this.props.organization.experiments.MetricAlertOnProjectCreationExperiment;
     return (
       <Fragment>
         <PageHeadingWithTopMargins withMargins>
           {t('Set your default alert settings')}
         </PageHeadingWithTopMargins>
-        <RadioGroupWithPadding
-          choices={issueAlertOptionsChoices}
-          label={t('Options for creating an alert')}
-          onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
-          value={this.state.alertSetting}
-        />
+        <Content>
+          {showMetricAlertSelections && <Subheading>{t('Issue Alerts')}</Subheading>}
+          <RadioGroupWithPadding
+            choices={issueAlertOptionsChoices}
+            label={t('Options for creating an alert')}
+            onChange={alertSetting => this.setStateAndUpdateParents({alertSetting})}
+            value={this.state.alertSetting}
+          />
+          {showMetricAlertSelections && (
+            <Fragment>
+              <Subheading>{t('Performance Alerts')}</Subheading>
+              <MultipleCheckboxField
+                size="24px"
+                choices={PRESET_AGGREGATES.map(agg => ({
+                  title: agg.description,
+                  value: agg.id,
+                  checked: this.state.metricAlertPresets.has(agg.id),
+                }))}
+                css={CheckboxFieldStyles}
+                onClick={selectedItem => {
+                  const next = new Set(this.state.metricAlertPresets);
+                  if (next.has(selectedItem)) {
+                    next.delete(selectedItem);
+                  } else {
+                    next.add(selectedItem);
+                  }
+                  this.setStateAndUpdateParents({
+                    metricAlertPresets: next,
+                  });
+                }}
+              />
+            </Fragment>
+          )}
+        </Content>
       </Fragment>
     );
   }
@@ -304,6 +343,15 @@ class IssueAlertOptions extends AsyncComponent<Props, State> {
 
 export default withOrganization(IssueAlertOptions);
 
+const CheckboxFieldStyles = css`
+  margin-top: ${space(1)};
+`;
+
+const Content = styled('div')`
+  padding-top: ${space(2)};
+  padding-bottom: ${space(4)};
+`;
+
 const CustomizeAlertsGrid = styled('div')`
   display: grid;
   grid-template-columns: repeat(5, max-content);
@@ -317,12 +365,13 @@ const InlineSelectControl = styled(SelectControl)`
   width: 160px;
 `;
 const RadioGroupWithPadding = styled(RadioGroup)`
-  padding: ${space(3)} 0;
-  margin-bottom: 50px;
-  box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
+  margin-bottom: ${space(2)};
 `;
 const PageHeadingWithTopMargins = styled(PageHeading)`
   margin-top: 65px;
+  margin-bottom: 0;
+  padding-bottom: ${space(3)};
+  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
 `;
 const RadioItemWrapper = styled('div')`
   min-height: 35px;
@@ -330,3 +379,6 @@ const RadioItemWrapper = styled('div')`
   flex-direction: column;
   justify-content: center;
 `;
+const Subheading = styled('b')`
+  display: block;
+`;