Skip to content

Commit cddb224

Browse files
authored
feat(anomaly detection): add preview chart to new alert form (#78238)
1 parent 9be7c3b commit cddb224

File tree

10 files changed

+648
-267
lines changed

10 files changed

+648
-267
lines changed

static/app/views/alerts/rules/metric/create.spec.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ describe('Incident Rules Create', function () {
2929
url: '/organizations/org-slug/events-stats/',
3030
body: EventsStatsFixture(),
3131
});
32+
MockApiClient.addMockResponse({
33+
url: '/organizations/org-slug/events/anomalies/',
34+
body: [],
35+
});
3236
MockApiClient.addMockResponse({
3337
url: '/organizations/org-slug/alert-rules/available-actions/',
3438
body: [

static/app/views/alerts/rules/metric/details/metricChartOption.tsx

Lines changed: 9 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import color from 'color';
2-
import type {MarkAreaComponentOption, YAXisComponentOption} from 'echarts';
2+
import type {YAXisComponentOption} from 'echarts';
33
import moment from 'moment-timezone';
44

55
import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart';
@@ -16,12 +16,9 @@ import {getCrashFreeRateSeries} from 'sentry/utils/sessions';
1616
import {lightTheme as theme} from 'sentry/utils/theme';
1717
import type {MetricRule, Trigger} from 'sentry/views/alerts/rules/metric/types';
1818
import {AlertRuleTriggerType, Dataset} from 'sentry/views/alerts/rules/metric/types';
19+
import {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart';
1920
import type {Anomaly, Incident} from 'sentry/views/alerts/types';
20-
import {
21-
AnomalyType,
22-
IncidentActivityType,
23-
IncidentStatus,
24-
} from 'sentry/views/alerts/types';
21+
import {IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types';
2522
import {
2623
ALERT_CHART_MIN_MAX_BUFFER,
2724
alertAxisFormatter,
@@ -140,48 +137,6 @@ function createIncidentSeries(
140137
};
141138
}
142139

143-
function createAnomalyMarkerSeries(
144-
lineColor: string,
145-
timestamp: string
146-
): AreaChartSeries {
147-
const formatter = ({value}: any) => {
148-
const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
149-
return [
150-
`<div class="tooltip-series"><div>`,
151-
`</div>Anomaly Detected</div>`,
152-
`<div class="tooltip-footer">${time}</div>`,
153-
'<div class="tooltip-arrow"></div>',
154-
].join('');
155-
};
156-
157-
return {
158-
seriesName: 'Anomaly Line',
159-
type: 'line',
160-
markLine: MarkLine({
161-
silent: false,
162-
lineStyle: {color: lineColor, type: 'dashed'},
163-
label: {
164-
silent: true,
165-
show: false,
166-
},
167-
data: [
168-
{
169-
xAxis: timestamp,
170-
},
171-
],
172-
tooltip: {
173-
formatter,
174-
},
175-
}),
176-
data: [],
177-
tooltip: {
178-
trigger: 'item',
179-
alwaysShowContent: true,
180-
formatter,
181-
},
182-
};
183-
}
184-
185140
export type MetricChartData = {
186141
rule: MetricRule;
187142
timeseriesData: Series[];
@@ -263,8 +218,11 @@ export function getMetricAlertChartOption({
263218
) / ALERT_CHART_MIN_MAX_BUFFER
264219
)
265220
: 0;
266-
const firstPoint = new Date(dataArr[0]?.name).getTime();
267-
const lastPoint = new Date(dataArr[dataArr.length - 1]?.name).getTime();
221+
const startDate = new Date(dataArr[0]?.name);
222+
const endDate =
223+
dataArr.length > 1 ? new Date(dataArr[dataArr.length - 1]?.name) : new Date();
224+
const firstPoint = startDate.getTime();
225+
const lastPoint = endDate.getTime();
268226
const totalDuration = lastPoint - firstPoint;
269227
let waitingForDataDuration = 0;
270228
let criticalDuration = 0;
@@ -403,77 +361,8 @@ export function getMetricAlertChartOption({
403361
});
404362
}
405363
if (anomalies) {
406-
const anomalyBlocks: MarkAreaComponentOption['data'] = [];
407-
let start: string | undefined;
408-
let end: string | undefined;
409-
anomalies
410-
.filter(anomalyts => {
411-
const ts = new Date(anomalyts.timestamp).getTime();
412-
return firstPoint < ts && ts < lastPoint;
413-
})
414-
.forEach(anomalyts => {
415-
const {anomaly, timestamp} = anomalyts;
416-
417-
if (
418-
[AnomalyType.high, AnomalyType.low].includes(anomaly.anomaly_type as string)
419-
) {
420-
if (!start) {
421-
// If this is the start of an anomaly, set start
422-
start = new Date(timestamp).toISOString();
423-
}
424-
// as long as we have an valid anomaly type - continue tracking until we've hit the end
425-
end = new Date(timestamp).toISOString();
426-
} else {
427-
if (start && end) {
428-
// If we've hit a non-anomaly type, push the block
429-
anomalyBlocks.push([
430-
{
431-
xAxis: start,
432-
},
433-
{
434-
xAxis: end,
435-
},
436-
]);
437-
// Create a marker line for the start of the anomaly
438-
series.push(createAnomalyMarkerSeries(theme.purple300, start));
439-
}
440-
// reset the start/end to capture the next anomaly block
441-
start = undefined;
442-
end = undefined;
443-
}
444-
});
445-
if (start && end) {
446-
// push in the last block
447-
// Create a marker line for the start of the anomaly
448-
series.push(createAnomalyMarkerSeries(theme.purple300, start));
449-
anomalyBlocks.push([
450-
{
451-
xAxis: start,
452-
},
453-
{
454-
xAxis: end,
455-
},
456-
]);
457-
}
458-
459-
// NOTE: if timerange is too small - highlighted area will not be visible
460-
// Possibly provide a minimum window size if the time range is too large?
461-
series.push({
462-
seriesName: '',
463-
name: 'Anomaly',
464-
type: 'line',
465-
smooth: true,
466-
data: [],
467-
markArea: {
468-
itemStyle: {
469-
color: 'rgba(255, 173, 177, 0.4)',
470-
},
471-
silent: true, // potentially don't make this silent if we want to render the `anomaly detected` in the tooltip
472-
data: anomalyBlocks,
473-
},
474-
});
364+
series.push(...getAnomalyMarkerSeries(anomalies, {startDate, endDate}));
475365
}
476-
477366
let maxThresholdValue = 0;
478367
if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
479368
const {alertThreshold} = warningTrigger;

static/app/views/alerts/rules/metric/ruleForm.spec.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jest.mock('sentry/utils/analytics', () => ({
3232
}));
3333

3434
describe('Incident Rules Form', () => {
35-
let organization, project, router, location;
35+
let organization, project, router, location, anomalies;
3636
// create wrapper
3737
const createWrapper = props =>
3838
render(
@@ -105,6 +105,11 @@ describe('Incident Rules Form', () => {
105105
url: '/organizations/org-slug/recent-searches/',
106106
body: [],
107107
});
108+
anomalies = MockApiClient.addMockResponse({
109+
method: 'POST',
110+
url: '/organizations/org-slug/events/anomalies/',
111+
body: [],
112+
});
108113
});
109114

110115
afterEach(() => {
@@ -391,6 +396,19 @@ describe('Incident Rules Form', () => {
391396
expect(
392397
await screen.findByRole('textbox', {name: 'Level of responsiveness'})
393398
).toBeInTheDocument();
399+
expect(anomalies).toHaveBeenLastCalledWith(
400+
expect.anything(),
401+
expect.objectContaining({
402+
data: expect.objectContaining({
403+
config: {
404+
direction: 'up',
405+
sensitivity: AlertRuleSensitivity.MEDIUM,
406+
expected_seasonality: AlertRuleSeasonality.AUTO,
407+
time_period: 60,
408+
},
409+
}),
410+
})
411+
);
394412
await userEvent.click(screen.getByLabelText('Save Rule'));
395413

396414
expect(createRule).toHaveBeenLastCalledWith(

0 commit comments

Comments
 (0)