Browse Source

Anomaly Charts (#76889)

highlights the alert rule details chart if an anomaly is detected

Added a dashed line to highlight when the anomaly started
and removed the label at the top of the chart
<img width="512" alt="Screenshot 2024-09-03 at 3 18 23 PM"
src="https://github.com/user-attachments/assets/3a1a04a1-f8a0-4325-8989-f9b9f61d974a">
<img width="284" alt="Screenshot 2024-09-03 at 3 21 35 PM"
src="https://github.com/user-attachments/assets/0013642a-cbf1-488e-b88f-739cc2f92256">
Nathan Hsieh 6 months ago
parent
commit
c0f55a527c

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

@@ -29,7 +29,7 @@ import {Dataset, TimePeriod} from 'sentry/views/alerts/rules/metric/types';
 import {extractEventTypeFilterFromRule} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
 import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
 import {getAlertRuleActionCategory} from 'sentry/views/alerts/rules/utils';
-import type {Incident} from 'sentry/views/alerts/types';
+import type {Anomaly, Incident} from 'sentry/views/alerts/types';
 import {AlertRuleStatus} from 'sentry/views/alerts/types';
 import {alertDetailsLink} from 'sentry/views/alerts/utils';
 import {MetricsBetaEndAlert} from 'sentry/views/metrics/metricsBetaEndAlert';
@@ -53,6 +53,7 @@ interface MetricDetailsBodyProps extends RouteComponentProps<{}, {}> {
   location: Location;
   organization: Organization;
   timePeriod: TimePeriodType;
+  anomalies?: Anomaly[];
   incidents?: Incident[];
   project?: Project;
   rule?: MetricRule;
@@ -69,6 +70,7 @@ export default function MetricDetailsBody({
   selectedIncident,
   location,
   router,
+  anomalies,
 }: MetricDetailsBodyProps) {
   function getPeriodInterval() {
     const startDate = moment.utc(timePeriod.start);
@@ -232,6 +234,7 @@ export default function MetricDetailsBody({
             api={api}
             rule={rule}
             incidents={incidents}
+            anomalies={anomalies}
             timePeriod={timePeriod}
             selectedIncident={selectedIncident}
             formattedAggregate={formattedAggregate}

+ 10 - 3
static/app/views/alerts/rules/metric/details/index.tsx

@@ -21,9 +21,10 @@ import withApi from 'sentry/utils/withApi';
 import withProjects from 'sentry/utils/withProjects';
 import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
 import {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
-import type {Incident} from 'sentry/views/alerts/types';
+import type {Anomaly, Incident} from 'sentry/views/alerts/types';
 import {
   fetchAlertRule,
+  fetchAnomaliesForRule,
   fetchIncident,
   fetchIncidentsForRule,
 } from 'sentry/views/alerts/utils/apiCalls';
@@ -47,6 +48,7 @@ interface State {
   hasError: boolean;
   isLoading: boolean;
   selectedIncident: Incident | null;
+  anomalies?: Anomaly[];
   incidents?: Incident[];
   rule?: MetricRule;
 }
@@ -199,11 +201,15 @@ class MetricAlertDetails extends Component<Props, State> {
     const timePeriod = this.getTimePeriod(selectedIncident);
     const {start, end} = timePeriod;
     try {
-      const [incidents, rule] = await Promise.all([
+      const [incidents, rule, anomalies] = await Promise.all([
         fetchIncidentsForRule(organization.slug, ruleId, start, end),
         rulePromise,
+        organization.features.includes('anomaly-detection-alerts')
+          ? fetchAnomaliesForRule(organization.slug, ruleId, start, end)
+          : undefined,
       ]);
       this.setState({
+        anomalies,
         incidents,
         rule,
         selectedIncident,
@@ -230,7 +236,7 @@ class MetricAlertDetails extends Component<Props, State> {
   }
 
   render() {
-    const {rule, incidents, hasError, selectedIncident} = this.state;
+    const {rule, incidents, hasError, selectedIncident, anomalies} = this.state;
     const {organization, projects, loadingProjects} = this.props;
     const timePeriod = this.getTimePeriod(selectedIncident);
 
@@ -264,6 +270,7 @@ class MetricAlertDetails extends Component<Props, State> {
           rule={rule}
           project={project}
           incidents={incidents}
+          anomalies={anomalies}
           timePeriod={timePeriod}
           selectedIncident={selectedIncident}
         />

+ 4 - 1
static/app/views/alerts/rules/metric/details/metricChart.tsx

@@ -68,7 +68,7 @@ import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
 import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
 import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
 
-import type {Incident} from '../../../types';
+import type {Anomaly, Incident} from '../../../types';
 import {
   alertDetailsLink,
   alertTooltipValueFormatter,
@@ -93,6 +93,7 @@ type Props = WithRouterProps & {
   query: string;
   rule: MetricRule;
   timePeriod: TimePeriodType;
+  anomalies?: Anomaly[];
   formattedAggregate?: string;
   incidents?: Incident[];
   isOnDemandAlert?: boolean;
@@ -271,6 +272,7 @@ class MetricChart extends PureComponent<Props, State> {
     comparisonTimeseriesData?: Series[]
   ) {
     const {
+      anomalies,
       router,
       selectedIncident,
       interval,
@@ -307,6 +309,7 @@ class MetricChart extends PureComponent<Props, State> {
       rule,
       seriesName: formattedAggregate,
       incidents,
+      anomalies,
       selectedIncident,
       showWaitingForData:
         shouldShowOnDemandMetricAlertUI(organization) && this.props.isOnDemandAlert,

+ 123 - 4
static/app/views/alerts/rules/metric/details/metricChartOption.tsx

@@ -1,5 +1,5 @@
 import color from 'color';
-import type {YAXisComponentOption} from 'echarts';
+import type {MarkAreaComponentOption, YAXisComponentOption} from 'echarts';
 import moment from 'moment-timezone';
 
 import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart';
@@ -16,8 +16,12 @@ import {getCrashFreeRateSeries} from 'sentry/utils/sessions';
 import {lightTheme as theme} from 'sentry/utils/theme';
 import type {MetricRule, Trigger} from 'sentry/views/alerts/rules/metric/types';
 import {AlertRuleTriggerType, Dataset} from 'sentry/views/alerts/rules/metric/types';
-import type {Incident} from 'sentry/views/alerts/types';
-import {IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types';
+import type {Anomaly, Incident} from 'sentry/views/alerts/types';
+import {
+  AnomalyType,
+  IncidentActivityType,
+  IncidentStatus,
+} from 'sentry/views/alerts/types';
 import {
   ALERT_CHART_MIN_MAX_BUFFER,
   alertAxisFormatter,
@@ -136,9 +140,52 @@ function createIncidentSeries(
   };
 }
 
+function createAnomalyMarkerSeries(
+  lineColor: string,
+  timestamp: string
+): AreaChartSeries {
+  const formatter = ({value}: any) => {
+    const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
+    return [
+      `<div class="tooltip-series"><div>`,
+      `</div>Anomaly Detected</div>`,
+      `<div class="tooltip-footer">${time}</div>`,
+      '<div class="tooltip-arrow"></div>',
+    ].join('');
+  };
+
+  return {
+    seriesName: 'Anomaly Line',
+    type: 'line',
+    markLine: MarkLine({
+      silent: false,
+      lineStyle: {color: lineColor, type: 'dashed'},
+      label: {
+        silent: true,
+        show: false,
+      },
+      data: [
+        {
+          xAxis: timestamp,
+        },
+      ],
+      tooltip: {
+        formatter,
+      },
+    }),
+    data: [],
+    tooltip: {
+      trigger: 'item',
+      alwaysShowContent: true,
+      formatter,
+    },
+  };
+}
+
 export type MetricChartData = {
   rule: MetricRule;
   timeseriesData: Series[];
+  anomalies?: Anomaly[];
   handleIncidentClick?: (incident: Incident) => void;
   incidents?: Incident[];
   selectedIncident?: Incident | null;
@@ -162,6 +209,7 @@ export function getMetricAlertChartOption({
   selectedIncident,
   handleIncidentClick,
   showWaitingForData,
+  anomalies,
 }: MetricChartData): MetricChartOption {
   let criticalTrigger: Trigger | undefined;
   let warningTrigger: Trigger | undefined;
@@ -237,7 +285,6 @@ export function getMetricAlertChartOption({
   }
 
   if (incidents) {
-    // select incidents that fall within the graph range
     incidents
       .filter(
         incident =>
@@ -339,6 +386,7 @@ export function getMetricAlertChartOption({
           const selectedIncidentColor =
             incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100;
 
+          // Is areaSeries used anywhere?
           areaSeries.push({
             seriesName: '',
             type: 'line',
@@ -355,6 +403,77 @@ export function getMetricAlertChartOption({
       });
   }
 
+  if (anomalies) {
+    const anomalyBlocks: MarkAreaComponentOption['data'] = [];
+    let start: string | undefined;
+    let end: string | undefined;
+    anomalies
+      .filter(anomalyts => {
+        const ts = new Date(anomalyts.timestamp).getTime();
+        return firstPoint < ts && ts < lastPoint;
+      })
+      .forEach(anomalyts => {
+        const {anomaly, timestamp} = anomalyts;
+
+        if (
+          [AnomalyType.high, AnomalyType.low].includes(anomaly.anomaly_type as string)
+        ) {
+          if (!start) {
+            // If this is the start of an anomaly, set start
+            start = new Date(timestamp).toISOString();
+          }
+          // as long as we have an valid anomaly type - continue tracking until we've hit the end
+          end = new Date(timestamp).toISOString();
+        } else {
+          if (start && end) {
+            // If we've hit a non-anomaly type, push the block
+            anomalyBlocks.push([
+              {
+                xAxis: start,
+              },
+              {
+                xAxis: end,
+              },
+            ]);
+            // Create a marker line for the start of the anomaly
+            series.push(createAnomalyMarkerSeries(theme.purple300, start));
+          }
+          // reset the start/end to capture the next anomaly block
+          start = undefined;
+          end = undefined;
+        }
+      });
+    if (start && end) {
+      // push in the last block
+      anomalyBlocks.push([
+        {
+          name: 'Anomaly Detected',
+          xAxis: start,
+        },
+        {
+          xAxis: end,
+        },
+      ]);
+    }
+
+    // NOTE: if timerange is too small - highlighted area will not be visible
+    // Possibly provide a minimum window size if the time range is too large?
+    series.push({
+      seriesName: '',
+      name: 'Anomaly',
+      type: 'line',
+      smooth: true,
+      data: [],
+      markArea: {
+        itemStyle: {
+          color: 'rgba(255, 173, 177, 0.4)',
+        },
+        silent: true, // potentially don't make this silent if we want to render the `anomaly detected` in the tooltip
+        data: anomalyBlocks,
+      },
+    });
+  }
+
   let maxThresholdValue = 0;
   if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
     const {alertThreshold} = warningTrigger;

+ 15 - 0
static/app/views/alerts/types.tsx

@@ -111,3 +111,18 @@ export interface UptimeAlert extends UptimeRule {
 export type CombinedMetricIssueAlerts = IssueAlert | MetricAlert;
 
 export type CombinedAlerts = CombinedMetricIssueAlerts | UptimeAlert;
+
+// TODO: This is a placeholder type for now
+// Assume this is a timestamp of when the anomaly occurred and for how long
+export type Anomaly = {
+  anomaly: {[key: string]: number | string};
+  timestamp: string;
+  value: number;
+};
+
+export const AnomalyType = {
+  high: 'anomaly_higher_confidence',
+  low: 'anomaly_lower_confidence',
+  none: 'none',
+  noData: 'no_data',
+};

+ 20 - 3
static/app/views/alerts/utils/apiCalls.tsx

@@ -1,7 +1,7 @@
 import {Client} from 'sentry/api';
 import type {MetricRule} from 'sentry/views/alerts/rules/metric/types';
 
-import type {Incident} from '../types';
+import type {Anomaly, Incident} from '../types';
 
 // Use this api for requests that are getting cancelled
 const uncancellableApi = new Client();
@@ -19,14 +19,14 @@ export function fetchAlertRule(
 
 export function fetchIncidentsForRule(
   orgId: string,
-  alertRule: string,
+  ruleId: string,
   start: string,
   end: string
 ): Promise<Incident[]> {
   return uncancellableApi.requestPromise(`/organizations/${orgId}/incidents/`, {
     query: {
       project: '-1',
-      alertRule,
+      alertRule: ruleId,
       includeSnapshots: true,
       start,
       end,
@@ -42,3 +42,20 @@ export function fetchIncident(
 ): Promise<Incident> {
   return api.requestPromise(`/organizations/${orgId}/incidents/${alertId}/`);
 }
+
+export function fetchAnomaliesForRule(
+  orgId: string,
+  ruleId: string,
+  start: string,
+  end: string
+): Promise<Anomaly[]> {
+  return uncancellableApi.requestPromise(
+    `/organizations/${orgId}/alert-rules/${ruleId}/anomalies/`,
+    {
+      query: {
+        start,
+        end,
+      },
+    }
+  );
+}