Browse Source

Activated Alert rule detail (#70092)

Display activated alert rule activations and activator within the
details page

![Screenshot 2024-05-01 at 2 45
17 PM](https://github.com/getsentry/sentry/assets/6186377/fd05ed5f-a664-4ac6-9aef-8f2f29bc610a)
Nathan Hsieh 10 months ago
parent
commit
228c0238e8

+ 18 - 0
fixtures/js-stubs/metricRuleActivation.ts

@@ -0,0 +1,18 @@
+import type {AlertRuleActivation} from 'sentry/types/alerts';
+
+export function MetricRuleActivationFixture(
+  params: Partial<AlertRuleActivation> = {}
+): AlertRuleActivation {
+  return {
+    activator: '1234',
+    alertRuleId: '1234',
+    conditionType: '0',
+    dateCreated: '2019-07-31T23:02:02.731Z',
+    finishedAt: '',
+    id: '1',
+    isComplete: false,
+    querySubscriptionId: '1',
+    metricValue: 0,
+    ...params,
+  };
+}

+ 6 - 2
src/sentry/api/serializers/models/alert_rule_activations.py

@@ -7,12 +7,14 @@ from sentry.incidents.models.alert_rule_activations import AlertRuleActivations
 
 class AlertRuleActivationsResponse(TypedDict):
     id: str
+    activator: str
     alertRuleId: str
+    conditionType: str
     dateCreated: datetime
     finishedAt: datetime
+    isComplete: bool
     metricValue: int
     querySubscriptionId: str
-    isComplete: bool
 
 
 @register(AlertRuleActivations)
@@ -20,10 +22,12 @@ class AlertRuleActivationsSerializer(Serializer):
     def serialize(self, obj, attrs, user, **kwargs) -> AlertRuleActivationsResponse:
         return {
             "id": str(obj.id),
+            "activator": obj.activator,
             "alertRuleId": str(obj.alert_rule_id),
+            "conditionType": str(obj.condition_type),
             "dateCreated": obj.date_added,
             "finishedAt": obj.finished_at,
+            "isComplete": obj.is_complete(),
             "metricValue": obj.metric_value,
             "querySubscriptionId": str(obj.query_subscription_id),
-            "isComplete": obj.is_complete(),
         }

+ 4 - 0
src/sentry/api/serializers/models/incident.py

@@ -41,6 +41,9 @@ class IncidentSerializer(Serializer):
         for incident in item_list:
             results[incident] = {"projects": incident_projects.get(incident.id, [])}
             results[incident]["alert_rule"] = alert_rules.get(str(incident.alert_rule.id))
+            results[incident]["activation"] = (
+                serialize(incident.activation) if incident.activation else []
+            )
 
         if "seen_by" in self.expand:
             incident_seen_list = list(
@@ -88,6 +91,7 @@ class IncidentSerializer(Serializer):
             "dateDetected": obj.date_detected,
             "dateCreated": obj.date_added,
             "dateClosed": date_closed,
+            "activation": attrs.get("activation", []),
         }
 
 

+ 2 - 0
src/sentry/apidocs/examples/metric_alert_examples.py

@@ -229,6 +229,8 @@ class MetricAlertExamples:
                     "metricValue": 100,
                     "querySubscriptionId": "1",
                     "isComplete": True,
+                    "activator": "1",
+                    "conditionType": "0",
                 }
             ],
         )

+ 3 - 1
src/sentry/incidents/subscription_processor.py

@@ -441,12 +441,14 @@ class SubscriptionProcessor:
             return
 
         if not hasattr(self, "alert_rule"):
+            # QuerySubscriptions must _always_ have an associated AlertRule
             # If the alert rule has been removed then just skip
             metrics.incr("incidents.alert_rules.no_alert_rule_for_subscription")
             logger.error(
                 "Received an update for a subscription, but no associated alert rule exists"
             )
-            # TODO: Delete subscription here.
+            # TODO: Delete QuerySubscription here
+            # TODO: Delete SnubaQuery here
             return
 
         if subscription_update["timestamp"] <= self.last_update:

+ 24 - 0
static/app/types/alerts.tsx

@@ -298,3 +298,27 @@ export enum ActivationConditionType {
   RELEASE_CREATION = 0,
   DEPLOY_CREATION = 1,
 }
+
+export type AlertRuleActivation = {
+  activator: string;
+  alertRuleId: string;
+  conditionType: string;
+  dateCreated: string;
+  finishedAt: string;
+  id: string;
+  isComplete: boolean;
+  querySubscriptionId: string;
+  metricValue?: number;
+};
+
+export enum ActivationTrigger {
+  ACTIVATED = 'activated',
+  FINISHED = 'finished',
+}
+
+export type ActivationTriggerActivity = {
+  activator: string;
+  conditionType: string;
+  dateCreated: string;
+  type: ActivationTrigger;
+};

+ 2 - 0
static/app/views/alerts/list/rules/alertRulesList.spec.tsx

@@ -357,6 +357,8 @@ describe('AlertRulesList', () => {
               id: '1',
               isComplete: false,
               querySubscriptionId: '1',
+              activator: '123',
+              conditionType: '0',
             },
           ],
           latestIncident: IncidentFixture({

+ 2 - 1
static/app/views/alerts/rules/metric/details/body.tsx

@@ -195,6 +195,7 @@ export default function MetricDetailsBody({
 
           <ErrorMigrationWarning project={project} rule={rule} />
 
+          {/* TODO: add activation start/stop into chart */}
           <MetricChart
             api={api}
             rule={rule}
@@ -210,7 +211,7 @@ export default function MetricDetailsBody({
           />
           <DetailWrapper>
             <ActivityWrapper>
-              <MetricHistory incidents={incidents} />
+              <MetricHistory incidents={incidents} activations={rule.activations} />
               {[Dataset.METRICS, Dataset.SESSIONS, Dataset.ERRORS].includes(dataset) && (
                 <RelatedIssues
                   organization={organization}

+ 173 - 0
static/app/views/alerts/rules/metric/details/metricActivity.tsx

@@ -0,0 +1,173 @@
+import {Fragment, type ReactElement} from 'react';
+import styled from '@emotion/styled';
+import moment from 'moment-timezone';
+
+import Duration from 'sentry/components/duration';
+import GlobalSelectionLink from 'sentry/components/globalSelectionLink';
+import Link from 'sentry/components/links/link';
+import {StatusIndicator} from 'sentry/components/statusIndicator';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {ActivationConditionType} from 'sentry/types/alerts';
+import type {Organization} from 'sentry/types/organization';
+import getDuration from 'sentry/utils/duration/getDuration';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {capitalize} from 'sentry/utils/string/capitalize';
+import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
+import {StyledDateTime} from 'sentry/views/alerts/rules/metric/details/styles';
+import {AlertRuleThresholdType} from 'sentry/views/alerts/rules/metric/types';
+import type {ActivityType, Incident} from 'sentry/views/alerts/types';
+import {IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types';
+import {alertDetailsLink} from 'sentry/views/alerts/utils';
+import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
+import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
+
+type MetricAlertActivityProps = {
+  incident: Incident;
+  organization: Organization;
+};
+
+function MetricAlertActivity({organization, incident}: MetricAlertActivityProps) {
+  // NOTE: while _possible_, we should never expect an incident to _not_ have a status_change activity
+  const activities: ActivityType[] = (incident.activities ?? []).filter(
+    activity => activity.type === IncidentActivityType.STATUS_CHANGE
+  );
+
+  const statusValues = [String(IncidentStatus.CRITICAL), String(IncidentStatus.WARNING)];
+  // TODO: kinda cheating with the forced `!`. Is there a better way to type this?
+  const latestActivity: ActivityType = activities.find(activity =>
+    statusValues.includes(String(activity.value))
+  )!;
+
+  const isCritical = Number(latestActivity.value) === IncidentStatus.CRITICAL;
+
+  // Find the _final_ most recent activity _after_ our triggered activity
+  // This exists for the `CLOSED` state (or any state NOT WARNING/CRITICAL)
+  const finalActivity = activities.find(
+    activity => activity.previousValue === latestActivity.value
+  );
+  const activityDuration = (
+    finalActivity ? moment(finalActivity.dateCreated) : moment()
+  ).diff(moment(latestActivity.dateCreated), 'milliseconds');
+
+  const triggerLabel = isCritical ? 'critical' : 'warning';
+  const curentTrigger = incident.alertRule.triggers.find(
+    trigger => trigger.label === triggerLabel
+  );
+  const timeWindow = getDuration(incident.alertRule.timeWindow * 60);
+  const alertName = capitalize(
+    AlertWizardAlertNames[getAlertTypeFromAggregateDataset(incident.alertRule)]
+  );
+
+  const project = incident.alertRule.projects[0];
+  const activation = incident.activation;
+  let activationBlock: ReactElement | null = null;
+  // TODO: Split this string check into a separate component
+  if (activation) {
+    let condition;
+    let activator;
+    switch (activation.conditionType) {
+      case String(ActivationConditionType.RELEASE_CREATION):
+        condition = 'Release';
+        activator = (
+          <GlobalSelectionLink
+            to={{
+              pathname: `/organizations/${
+                organization.slug
+              }/releases/${encodeURIComponent(activation.activator)}/`,
+              query: {project: project},
+            }}
+          >
+            {activation.activator}
+          </GlobalSelectionLink>
+        );
+        break;
+      case String(ActivationConditionType.DEPLOY_CREATION):
+        condition = 'Deploy';
+        activator = activation.activator;
+        break;
+      default:
+        condition = '--';
+    }
+    activationBlock = (
+      <div>
+        &nbsp;from {condition} {activator}
+      </div>
+    );
+  }
+
+  return (
+    <Fragment>
+      <Cell>
+        {latestActivity.value && (
+          <StatusIndicator
+            status={isCritical ? 'error' : 'warning'}
+            tooltipTitle={t('Status: %s', isCritical ? t('Critical') : t('Warning'))}
+          />
+        )}
+        <Link
+          to={{
+            pathname: alertDetailsLink(organization, incident),
+            query: {alert: incident.identifier},
+          }}
+        >
+          #{incident.identifier}
+        </Link>
+      </Cell>
+      <Cell>
+        {incident.alertRule.comparisonDelta ? (
+          <Fragment>
+            {alertName} {curentTrigger?.alertThreshold}%
+            {t(
+              ' %s in %s compared to the ',
+              incident.alertRule.thresholdType === AlertRuleThresholdType.ABOVE
+                ? t('higher')
+                : t('lower'),
+              timeWindow
+            )}
+            {COMPARISON_DELTA_OPTIONS.find(
+              ({value}) => value === incident.alertRule.comparisonDelta
+            )?.label ?? COMPARISON_DELTA_OPTIONS[0].label}
+          </Fragment>
+        ) : (
+          <Fragment>
+            {alertName}{' '}
+            {incident.alertRule.thresholdType === AlertRuleThresholdType.ABOVE
+              ? t('above')
+              : t('below')}{' '}
+            {curentTrigger?.alertThreshold || '_'} {t('within')} {timeWindow}
+            {activationBlock}
+          </Fragment>
+        )}
+      </Cell>
+      <Cell>
+        {activityDuration &&
+          getDynamicText({
+            value: <Duration abbreviation seconds={activityDuration / 1000} />,
+            fixed: '30s',
+          })}
+      </Cell>
+      <Cell>
+        <StyledDateTime
+          date={getDynamicText({
+            value: incident.dateCreated,
+            fixed: 'Mar 4, 2022 10:44:13 AM UTC',
+          })}
+          year
+          seconds
+          timeZone
+        />
+      </Cell>
+    </Fragment>
+  );
+}
+
+export default MetricAlertActivity;
+
+const Cell = styled('div')`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  font-size: ${p => p.theme.fontSizeMedium};
+  padding: ${space(1)};
+`;

+ 27 - 1
static/app/views/alerts/rules/metric/details/metricHistory.spec.tsx

@@ -1,5 +1,6 @@
 import range from 'lodash/range';
 import {IncidentFixture} from 'sentry-fixture/incident';
+import {MetricRuleActivationFixture} from 'sentry-fixture/metricRuleActivation';
 
 import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
@@ -14,7 +15,9 @@ describe('MetricHistory', () => {
   it('renders a critical incident', () => {
     render(<MetricHistory incidents={[IncidentFixture()]} />);
     expect(screen.getByRole('link', {name: '#123'})).toBeInTheDocument();
-    expect(screen.getByText('Number of errors above 70 in 1 hour')).toBeInTheDocument();
+    expect(
+      screen.getByText('Number of errors above 70 within 1 hour')
+    ).toBeInTheDocument();
     expect(screen.getByText('12hr')).toBeInTheDocument();
   });
 
@@ -44,4 +47,27 @@ describe('MetricHistory', () => {
     render(<MetricHistory incidents={incidents} />);
     expect(screen.getByText('No alerts triggered during this time.')).toBeInTheDocument();
   });
+
+  it('renders activation starts and ends', () => {
+    // render 1 activation that has completed
+    // render 1 activation that has not finished yet
+    const activations = [
+      MetricRuleActivationFixture({
+        id: `1`,
+        activator: '1',
+        dateCreated: '2024-05-02T12:00:00.123Z',
+        isComplete: true,
+        finishedAt: '2024-05-02T13:00:00.123Z',
+      }),
+      MetricRuleActivationFixture({
+        id: `2`,
+        activator: '2',
+        dateCreated: '2024-05-02T17:00:00.123Z',
+      }),
+    ];
+    render(<MetricHistory incidents={[]} activations={activations} />);
+
+    expect(screen.getAllByText('Start monitoring.').length).toBe(2);
+    expect(screen.getAllByText('Finish monitoring.').length).toBe(1);
+  });
 });

Some files were not shown because too many files changed in this diff