diff --git a/static/app/views/alerts/rules/metric/create.spec.tsx b/static/app/views/alerts/rules/metric/create.spec.tsx
index 8ac9eb80640b40..0ddae132b29275 100644
--- a/static/app/views/alerts/rules/metric/create.spec.tsx
+++ b/static/app/views/alerts/rules/metric/create.spec.tsx
@@ -29,6 +29,10 @@ describe('Incident Rules Create', function () {
url: '/organizations/org-slug/events-stats/',
body: EventsStatsFixture(),
});
+ MockApiClient.addMockResponse({
+ url: '/organizations/org-slug/events/anomalies/',
+ body: [],
+ });
MockApiClient.addMockResponse({
url: '/organizations/org-slug/alert-rules/available-actions/',
body: [
diff --git a/static/app/views/alerts/rules/metric/details/metricChartOption.tsx b/static/app/views/alerts/rules/metric/details/metricChartOption.tsx
index e5caf55e667af0..f895a141d473ae 100644
--- a/static/app/views/alerts/rules/metric/details/metricChartOption.tsx
+++ b/static/app/views/alerts/rules/metric/details/metricChartOption.tsx
@@ -1,5 +1,5 @@
import color from 'color';
-import type {MarkAreaComponentOption, YAXisComponentOption} from 'echarts';
+import type {YAXisComponentOption} from 'echarts';
import moment from 'moment-timezone';
import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart';
@@ -16,12 +16,9 @@ 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 {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart';
import type {Anomaly, Incident} from 'sentry/views/alerts/types';
-import {
- AnomalyType,
- IncidentActivityType,
- IncidentStatus,
-} from 'sentry/views/alerts/types';
+import {IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types';
import {
ALERT_CHART_MIN_MAX_BUFFER,
alertAxisFormatter,
@@ -140,48 +137,6 @@ function createIncidentSeries(
};
}
-function createAnomalyMarkerSeries(
- lineColor: string,
- timestamp: string
-): AreaChartSeries {
- const formatter = ({value}: any) => {
- const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
- return [
- `
`,
- ``,
- '',
- ].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[];
@@ -263,8 +218,11 @@ export function getMetricAlertChartOption({
) / ALERT_CHART_MIN_MAX_BUFFER
)
: 0;
- const firstPoint = new Date(dataArr[0]?.name).getTime();
- const lastPoint = new Date(dataArr[dataArr.length - 1]?.name).getTime();
+ const startDate = new Date(dataArr[0]?.name);
+ const endDate =
+ dataArr.length > 1 ? new Date(dataArr[dataArr.length - 1]?.name) : new Date();
+ const firstPoint = startDate.getTime();
+ const lastPoint = endDate.getTime();
const totalDuration = lastPoint - firstPoint;
let waitingForDataDuration = 0;
let criticalDuration = 0;
@@ -403,77 +361,8 @@ 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
- // Create a marker line for the start of the anomaly
- series.push(createAnomalyMarkerSeries(theme.purple300, start));
- anomalyBlocks.push([
- {
- 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,
- },
- });
+ series.push(...getAnomalyMarkerSeries(anomalies, {startDate, endDate}));
}
-
let maxThresholdValue = 0;
if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
const {alertThreshold} = warningTrigger;
diff --git a/static/app/views/alerts/rules/metric/ruleForm.spec.tsx b/static/app/views/alerts/rules/metric/ruleForm.spec.tsx
index 44d196ca2014db..38081c9237e28c 100644
--- a/static/app/views/alerts/rules/metric/ruleForm.spec.tsx
+++ b/static/app/views/alerts/rules/metric/ruleForm.spec.tsx
@@ -32,7 +32,7 @@ jest.mock('sentry/utils/analytics', () => ({
}));
describe('Incident Rules Form', () => {
- let organization, project, router, location;
+ let organization, project, router, location, anomalies;
// create wrapper
const createWrapper = props =>
render(
@@ -105,6 +105,11 @@ describe('Incident Rules Form', () => {
url: '/organizations/org-slug/recent-searches/',
body: [],
});
+ anomalies = MockApiClient.addMockResponse({
+ method: 'POST',
+ url: '/organizations/org-slug/events/anomalies/',
+ body: [],
+ });
});
afterEach(() => {
@@ -391,6 +396,19 @@ describe('Incident Rules Form', () => {
expect(
await screen.findByRole('textbox', {name: 'Level of responsiveness'})
).toBeInTheDocument();
+ expect(anomalies).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ config: {
+ direction: 'up',
+ sensitivity: AlertRuleSensitivity.MEDIUM,
+ expected_seasonality: AlertRuleSeasonality.AUTO,
+ time_period: 60,
+ },
+ }),
+ })
+ );
await userEvent.click(screen.getByLabelText('Save Rule'));
expect(createRule).toHaveBeenLastCalledWith(
diff --git a/static/app/views/alerts/rules/metric/ruleForm.tsx b/static/app/views/alerts/rules/metric/ruleForm.tsx
index 78f07fdb81be8f..f0d0bcc6987ddf 100644
--- a/static/app/views/alerts/rules/metric/ruleForm.tsx
+++ b/static/app/views/alerts/rules/metric/ruleForm.tsx
@@ -1,4 +1,4 @@
-import type {ReactNode} from 'react';
+import type {ComponentProps, ReactNode} from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
@@ -53,13 +53,13 @@ import {IncompatibleAlertQuery} from 'sentry/views/alerts/rules/metric/incompati
import RuleNameOwnerForm from 'sentry/views/alerts/rules/metric/ruleNameOwnerForm';
import ThresholdTypeForm from 'sentry/views/alerts/rules/metric/thresholdTypeForm';
import Triggers from 'sentry/views/alerts/rules/metric/triggers';
-import TriggersChart from 'sentry/views/alerts/rules/metric/triggers/chart';
+import TriggersChart, {ErrorChart} from 'sentry/views/alerts/rules/metric/triggers/chart';
import {getEventTypeFilter} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter';
import hasThresholdValue from 'sentry/views/alerts/rules/metric/utils/hasThresholdValue';
import {isCustomMetricAlert} from 'sentry/views/alerts/rules/metric/utils/isCustomMetricAlert';
import {isInsightsMetricAlert} from 'sentry/views/alerts/rules/metric/utils/isInsightsMetricAlert';
import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert';
-import {AlertRuleType} from 'sentry/views/alerts/types';
+import {AlertRuleType, type Anomaly} from 'sentry/views/alerts/types';
import {ruleNeedsErrorMigration} from 'sentry/views/alerts/utils/migrationUi';
import type {MetricAlertType} from 'sentry/views/alerts/wizard/options';
import {
@@ -105,6 +105,8 @@ type RuleTaskResponse = {
error?: string;
};
+type HistoricalDataset = ReturnType;
+
type Props = {
organization: Organization;
project: Project;
@@ -125,14 +127,17 @@ type Props = {
type State = {
aggregate: string;
alertType: MetricAlertType;
+ anomalies: Anomaly[];
// `null` means loading
availableActions: MetricActionTemplate[] | null;
comparisonType: AlertRuleComparisonType;
+ currentData: HistoricalDataset;
// Rule conditions form inputs
// Needed for TriggersChart
dataset: Dataset;
environment: string | null;
eventTypes: EventTypes[];
+ historicalData: HistoricalDataset;
isQueryValid: boolean;
project: Project;
query: string;
@@ -144,6 +149,8 @@ type State = {
triggerErrors: Map;
triggers: Trigger[];
activationCondition?: ActivationConditionType;
+ chartError?: boolean;
+ chartErrorMessage?: string;
comparisonDelta?: number;
isExtrapolatedChartData?: boolean;
monitorType?: MonitorType;
@@ -157,6 +164,12 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
pollingTimeout: number | undefined = undefined;
uuid: string | null = null;
+ constructor(props, context) {
+ super(props, context);
+ this.handleHistoricalTimeSeriesDataFetched =
+ this.handleHistoricalTimeSeriesDataFetched.bind(this);
+ }
+
get isDuplicateRule(): boolean {
return Boolean(this.props.isDuplicateRule);
}
@@ -216,7 +229,9 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
return {
...super.getDefaultState(),
-
+ currentData: [],
+ historicalData: [],
+ anomalies: [],
name: name ?? rule.name ?? '',
aggregate,
dataset,
@@ -538,7 +553,10 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
handleFieldChange = (name: string, value: unknown) => {
const {projects} = this.props;
- const {timeWindow} = this.state;
+ const {timeWindow, chartError} = this.state;
+ if (chartError) {
+ this.setState({chartError: false, chartErrorMessage: undefined});
+ }
if (name === 'alertType') {
if (value === 'crash_free_sessions' || value === 'crash_free_users') {
@@ -879,26 +897,35 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
clearIndicators();
}
- return {triggers, triggerErrors, triggersHaveChanged: true};
+ return {
+ triggers,
+ triggerErrors,
+ triggersHaveChanged: true,
+ chartError: false,
+ chartErrorMessage: undefined,
+ };
});
};
handleSensitivityChange = (sensitivity: AlertRuleSensitivity) => {
- this.setState({sensitivity});
+ this.setState({sensitivity}, () => this.fetchAnomalies());
};
handleThresholdTypeChange = (thresholdType: AlertRuleThresholdType) => {
const {triggers} = this.state;
const triggerErrors = this.validateTriggers(triggers, thresholdType);
- this.setState(state => ({
- thresholdType,
- triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
- }));
+ this.setState(
+ state => ({
+ thresholdType,
+ triggerErrors: new Map([...triggerErrors, ...state.triggerErrors]),
+ }),
+ () => this.fetchAnomalies()
+ );
};
handleThresholdPeriodChange = (value: number) => {
- this.setState({thresholdPeriod: value});
+ this.setState({thresholdPeriod: value}, () => this.fetchAnomalies());
};
handleResolveThresholdChange = (
@@ -922,6 +949,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
let updateState = {};
switch (value) {
case AlertRuleComparisonType.DYNAMIC:
+ this.fetchAnomalies();
updateState = {
comparisonType: value,
comparisonDelta: undefined,
@@ -954,7 +982,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
default:
break;
}
- this.setState(updateState);
+ this.setState({...updateState, chartError: false, chartErrorMessage: undefined});
};
handleDeleteRule = async () => {
@@ -1006,17 +1034,98 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
handleTimeSeriesDataFetched = (data: EventsStats | MultiSeriesEventsStats | null) => {
const {isExtrapolatedData} = data ?? {};
+ const currentData = formatStatsToHistoricalDataset(data);
+ const newState: Partial = {currentData};
if (shouldShowOnDemandMetricAlertUI(this.props.organization)) {
- this.setState({isExtrapolatedChartData: Boolean(isExtrapolatedData)});
+ newState.isExtrapolatedChartData = Boolean(isExtrapolatedData);
}
-
+ this.setState(newState, () => this.fetchAnomalies());
const {dataset, aggregate, query} = this.state;
if (!isOnDemandMetricAlert(dataset, aggregate, query)) {
this.handleMEPAlertDataset(data);
}
};
+ handleHistoricalTimeSeriesDataFetched(
+ data: EventsStats | MultiSeriesEventsStats | null
+ ) {
+ const historicalData = formatStatsToHistoricalDataset(data);
+ this.setState({historicalData}, () => this.fetchAnomalies());
+ }
+
+ async fetchAnomalies() {
+ if (this.state.comparisonType !== AlertRuleComparisonType.DYNAMIC) {
+ return;
+ }
+ const {organization, project} = this.props;
+ const {
+ timeWindow,
+ sensitivity,
+ seasonality,
+ thresholdType,
+ historicalData,
+ currentData,
+ } = this.state;
+ if (!(Array.isArray(currentData) && Array.isArray(historicalData))) {
+ return;
+ }
+ const direction =
+ thresholdType === AlertRuleThresholdType.ABOVE
+ ? 'up'
+ : thresholdType === AlertRuleThresholdType.BELOW
+ ? 'down'
+ : 'both';
+
+ // extract the earliest timestamp from the current dataset
+ const startOfCurrentTimeframe = currentData.reduce(
+ (value, [timestamp]) => (value < timestamp ? value : timestamp),
+ Infinity
+ );
+ const params = {
+ organization_id: organization.id,
+ project_id: project.id,
+ config: {
+ time_period: timeWindow,
+ sensitivity,
+ direction,
+ expected_seasonality: seasonality,
+ },
+ // remove historical data that overlaps with current dataset
+ historical_data: historicalData.filter(
+ ([timestamp]) => timestamp < startOfCurrentTimeframe
+ ),
+ current_data: currentData,
+ };
+
+ try {
+ const [anomalies] = await this.api.requestPromise(
+ `/organizations/${organization.slug}/events/anomalies/`,
+ {method: 'POST', data: params}
+ );
+ this.setState({anomalies});
+ } catch (e) {
+ let chartErrorMessage: string | undefined;
+ if (e.responseJSON) {
+ if (typeof e.responseJSON === 'object' && e.responseJSON.detail) {
+ chartErrorMessage = e.responseJSON.detail;
+ }
+ if (typeof e.responseJSON === 'string') {
+ chartErrorMessage = e.responseJSON;
+ }
+ } else if (typeof e.message === 'string') {
+ chartErrorMessage = e.message;
+ } else {
+ chartErrorMessage = t('Something went wrong when rendering this chart.');
+ }
+
+ this.setState({
+ chartError: true,
+ chartErrorMessage,
+ });
+ }
+ }
+
// If the user is creating an on-demand metric alert, we want to override the dataset
// to be generic metrics instead of transactions
checkOnDemandMetricsDataset = (dataset: Dataset, query: string) => {
@@ -1067,8 +1176,21 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
dataset,
alertType,
isQueryValid,
+ anomalies,
+ chartError,
+ chartErrorMessage,
} = this.state;
+ if (chartError) {
+ return (
+
+ );
+ }
const isOnDemand = isOnDemandMetricAlert(dataset, aggregate, query);
let formattedAggregate = aggregate;
@@ -1076,10 +1198,11 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
formattedAggregate = formatMRIField(aggregate);
}
- const chartProps = {
+ const chartProps: ComponentProps = {
organization,
projects: [project],
triggers,
+ anomalies: comparisonType === AlertRuleComparisonType.DYNAMIC ? anomalies : [],
location,
query: this.chartQuery,
aggregate,
@@ -1097,6 +1220,8 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
showTotalCount:
!['custom_metrics', 'span_metrics'].includes(alertType) && !isOnDemand,
onDataLoaded: this.handleTimeSeriesDataFetched,
+ includeHistorical: comparisonType === AlertRuleComparisonType.DYNAMIC,
+ onHistoricalDataLoaded: this.handleHistoricalTimeSeriesDataFetched,
};
let formattedQuery = `event.type:${eventTypes?.join(',')}`;
@@ -1326,6 +1451,16 @@ class RuleFormContainer extends DeprecatedAsyncComponent {
}
}
+function formatStatsToHistoricalDataset(
+ data: EventsStats | MultiSeriesEventsStats | null
+): [number, {count: number}][] {
+ return Array.isArray(data?.data)
+ ? data.data.flatMap(([timestamp, entries]) =>
+ entries.map(entry => [timestamp, entry] as [number, {count: number}])
+ ) ?? []
+ : [];
+}
+
const Main = styled(Layout.Main)`
max-width: 1000px;
`;
diff --git a/static/app/views/alerts/rules/metric/triggers/chart/index.spec.tsx b/static/app/views/alerts/rules/metric/triggers/chart/index.spec.tsx
index fd32a4a9010158..b0fe9d82645ec5 100644
--- a/static/app/views/alerts/rules/metric/triggers/chart/index.spec.tsx
+++ b/static/app/views/alerts/rules/metric/triggers/chart/index.spec.tsx
@@ -36,6 +36,7 @@ describe('Incident Rules Create', () => {
render(
void;
+ onHistoricalDataLoaded?: (data: EventsStats | MultiSeriesEventsStats | null) => void;
showTotalCount?: boolean;
};
@@ -141,6 +147,16 @@ const SESSION_AGGREGATE_TO_HEADING = {
[SessionsAggregate.CRASH_FREE_USERS]: t('Total Users'),
};
+const HISTORICAL_TIME_PERIOD_MAP: Record = {
+ [TimePeriod.SIX_HOURS]: '678h',
+ [TimePeriod.ONE_DAY]: '29d',
+ [TimePeriod.THREE_DAYS]: '31d',
+ [TimePeriod.SEVEN_DAYS]: '35d',
+ [TimePeriod.FOURTEEN_DAYS]: '42d',
+};
+
+const noop: any = () => {};
+
type State = {
sampleRate: number;
statsPeriod: TimePeriod;
@@ -270,7 +286,6 @@ class TriggersChart extends PureComponent {
}
const alertType = getAlertTypeFromAggregateDataset({aggregate, dataset});
-
try {
const totalCount = await fetchTotalCount(api, organization.slug, {
field: [],
@@ -322,6 +337,7 @@ class TriggersChart extends PureComponent {
comparisonType,
organization,
showTotalCount,
+ anomalies = [],
} = this.props;
const {statsPeriod, totalCount} = this.state;
const statsPeriodOptions = this.availableTimePeriods[timeWindow];
@@ -364,6 +380,7 @@ class TriggersChart extends PureComponent {
comparisonMarkLines={comparisonMarkLines ?? []}
hideThresholdLines={comparisonType !== AlertRuleComparisonType.COUNT}
triggers={triggers}
+ anomalies={anomalies}
resolveThreshold={resolveThreshold}
thresholdType={thresholdType}
aggregate={aggregate}
@@ -417,6 +434,7 @@ class TriggersChart extends PureComponent {
dataset,
newAlertOrQuery,
onDataLoaded,
+ onHistoricalDataLoaded,
environment,
formattedAggregate,
comparisonDelta,
@@ -447,24 +465,158 @@ class TriggersChart extends PureComponent {
};
if (isOnDemandMetricAlert) {
+ const {sampleRate} = this.state;
+ const baseProps: EventsRequestProps = {
+ api,
+ organization,
+ query,
+ queryExtras,
+ sampleRate,
+ environment: environment ? [environment] : undefined,
+ project: projects.map(({id}) => Number(id)),
+ interval: `${timeWindow}m`,
+ comparisonDelta: comparisonDelta ? comparisonDelta * 60 : undefined,
+ yAxis: aggregate,
+ includePrevious: false,
+ currentSeriesNames: [formattedAggregate || aggregate],
+ partial: false,
+ includeTimeAggregation: false,
+ includeTransformedData: false,
+ limit: 15,
+ children: noop,
+ };
+
+ return (
+
+ {this.props.includeHistorical ? (
+
+ ) : null}
+
+ {({
+ loading,
+ errored,
+ errorMessage,
+ reloading,
+ timeseriesData,
+ comparisonTimeseriesData,
+ seriesAdditionalInfo,
+ }) => {
+ let comparisonMarkLines: LineChartSeries[] = [];
+ if (renderComparisonStats && comparisonTimeseriesData) {
+ comparisonMarkLines = getComparisonMarkLines(
+ timeseriesData,
+ comparisonTimeseriesData,
+ timeWindow,
+ triggers,
+ thresholdType
+ );
+ }
+
+ return this.renderChart({
+ timeseriesData: timeseriesData as Series[],
+ isLoading: loading,
+ isReloading: reloading,
+ comparisonData: comparisonTimeseriesData,
+ comparisonMarkLines,
+ errorMessage,
+ isQueryValid,
+ errored,
+ orgFeatures: organization.features,
+ seriesAdditionalInfo,
+ });
+ }}
+
+ );
+
+ );
+ }
+
+ if (isSessionAggregate(aggregate)) {
+ const baseProps: ComponentProps = {
+ api: api,
+ organization: organization,
+ project: projects.map(({id}) => Number(id)),
+ environment: environment ? [environment] : undefined,
+ statsPeriod: period,
+ query: query,
+ interval: TIME_WINDOW_TO_SESSION_INTERVAL[timeWindow],
+ field: SESSION_AGGREGATE_TO_FIELD[aggregate],
+ groupBy: ['session.status'],
+ children: noop,
+ };
return (
- Number(id))}
- interval={`${timeWindow}m`}
- comparisonDelta={comparisonDelta && comparisonDelta * 60}
- period={period}
- yAxis={aggregate}
- includePrevious={false}
- currentSeriesNames={[formattedAggregate || aggregate]}
- partial={false}
- queryExtras={queryExtras}
- sampleRate={this.state.sampleRate}
- dataLoadedCallback={onDataLoaded}
- >
+
+ {({loading, errored, reloading, response}) => {
+ const {groups, intervals} = response || {};
+ const sessionTimeSeries = [
+ {
+ seriesName:
+ AlertWizardAlertNames[
+ getAlertTypeFromAggregateDataset({
+ aggregate,
+ dataset: Dataset.SESSIONS,
+ })
+ ],
+ data: getCrashFreeRateSeries(
+ groups,
+ intervals,
+ SESSION_AGGREGATE_TO_FIELD[aggregate]
+ ),
+ },
+ ];
+
+ return this.renderChart({
+ timeseriesData: sessionTimeSeries,
+ isLoading: loading,
+ isReloading: reloading,
+ comparisonData: undefined,
+ comparisonMarkLines: undefined,
+ minutesThresholdToDisplaySeconds: MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
+ isQueryValid,
+ errored,
+ orgFeatures: organization.features,
+ });
+ }}
+
+ );
+ }
+
+ const baseProps = {
+ api,
+ organization,
+ query,
+ period,
+ queryExtras,
+ environment: environment ? [environment] : undefined,
+ project: projects.map(({id}) => Number(id)),
+ interval: `${timeWindow}m`,
+ comparisonDelta: comparisonDelta ? comparisonDelta * 60 : undefined,
+ yAxis: aggregate,
+ includePrevious: false,
+ currentSeriesNames: [formattedAggregate || aggregate],
+ partial: false,
+ };
+
+ return (
+
+ {this.props.includeHistorical ? (
+
+ {noop}
+
+ ) : null}
+
{({
loading,
errored,
@@ -472,7 +624,6 @@ class TriggersChart extends PureComponent {
reloading,
timeseriesData,
comparisonTimeseriesData,
- seriesAdditionalInfo,
}) => {
let comparisonMarkLines: LineChartSeries[] = [];
if (renderComparisonStats && comparisonTimeseriesData) {
@@ -495,104 +646,10 @@ class TriggersChart extends PureComponent {
isQueryValid,
errored,
orgFeatures: organization.features,
- seriesAdditionalInfo,
});
}}
-
- );
- }
-
- return isSessionAggregate(aggregate) ? (
- Number(id))}
- environment={environment ? [environment] : undefined}
- statsPeriod={period}
- query={query}
- interval={TIME_WINDOW_TO_SESSION_INTERVAL[timeWindow]}
- field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
- groupBy={['session.status']}
- >
- {({loading, errored, reloading, response}) => {
- const {groups, intervals} = response || {};
- const sessionTimeSeries = [
- {
- seriesName:
- AlertWizardAlertNames[
- getAlertTypeFromAggregateDataset({aggregate, dataset: Dataset.SESSIONS})
- ],
- data: getCrashFreeRateSeries(
- groups,
- intervals,
- SESSION_AGGREGATE_TO_FIELD[aggregate]
- ),
- },
- ];
-
- return this.renderChart({
- timeseriesData: sessionTimeSeries,
- isLoading: loading,
- isReloading: reloading,
- comparisonData: undefined,
- comparisonMarkLines: undefined,
- minutesThresholdToDisplaySeconds: MINUTES_THRESHOLD_TO_DISPLAY_SECONDS,
- isQueryValid,
- errored,
- orgFeatures: organization.features,
- });
- }}
-
- ) : (
- Number(id))}
- interval={`${timeWindow}m`}
- comparisonDelta={comparisonDelta && comparisonDelta * 60}
- period={period}
- yAxis={aggregate}
- includePrevious={false}
- currentSeriesNames={[formattedAggregate || aggregate]}
- partial={false}
- queryExtras={queryExtras}
- useOnDemandMetrics
- dataLoadedCallback={onDataLoaded}
- >
- {({
- loading,
- errored,
- errorMessage,
- reloading,
- timeseriesData,
- comparisonTimeseriesData,
- }) => {
- let comparisonMarkLines: LineChartSeries[] = [];
- if (renderComparisonStats && comparisonTimeseriesData) {
- comparisonMarkLines = getComparisonMarkLines(
- timeseriesData,
- comparisonTimeseriesData,
- timeWindow,
- triggers,
- thresholdType
- );
- }
-
- return this.renderChart({
- timeseriesData: timeseriesData as Series[],
- isLoading: loading,
- isReloading: reloading,
- comparisonData: comparisonTimeseriesData,
- comparisonMarkLines,
- errorMessage,
- isQueryValid,
- errored,
- orgFeatures: organization.features,
- });
- }}
-
+
+
);
}
}
@@ -621,9 +678,9 @@ const ChartErrorWrapper = styled('div')`
margin-top: ${space(2)};
`;
-function ErrorChart({isAllowIndexed, isQueryValid, errorMessage}) {
+export function ErrorChart({isAllowIndexed, isQueryValid, errorMessage, ...props}) {
return (
-
+
{!isAllowIndexed && !isQueryValid
? t('Your filter conditions contain an unsupported field - please review.')
diff --git a/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx b/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx
index 66d5868e945161..9ddfc01eb5d33c 100644
--- a/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx
+++ b/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx
@@ -15,6 +15,8 @@ import {space} from 'sentry/styles/space';
import type {PageFilters} from 'sentry/types/core';
import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
import theme from 'sentry/utils/theme';
+import {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart';
+import type {Anomaly} from 'sentry/views/alerts/types';
import {
ALERT_CHART_MIN_MAX_BUFFER,
alertAxisFormatter,
@@ -39,7 +41,9 @@ type Props = DefaultProps & {
resolveThreshold: MetricRule['resolveThreshold'];
thresholdType: MetricRule['thresholdType'];
triggers: Trigger[];
+ anomalies?: Anomaly[];
comparisonSeriesName?: string;
+ includePrevious?: boolean;
isExtrapolatedData?: boolean;
maxValue?: number;
minValue?: number;
@@ -316,6 +320,7 @@ export default class ThresholdsChart extends PureComponent {
comparisonMarkLines,
minutesThresholdToDisplaySeconds,
thresholdType,
+ anomalies = [],
} = this.props;
const dataWithoutRecentBucket = data?.map(({data: eventData, ...restOfData}) => {
@@ -431,7 +436,11 @@ export default class ThresholdsChart extends PureComponent {
]),
})}
colors={CHART_PALETTE[0]}
- series={[...dataWithoutRecentBucket, ...comparisonMarkLines]}
+ series={[
+ ...dataWithoutRecentBucket,
+ ...comparisonMarkLines,
+ ...getAnomalyMarkerSeries(anomalies),
+ ]}
additionalSeries={comparisonDataWithoutRecentBucket.map(
({data: _data, ...otherSeriesProps}) =>
LineSeries({
diff --git a/static/app/views/alerts/rules/metric/utils/anomalyChart.spec.tsx b/static/app/views/alerts/rules/metric/utils/anomalyChart.spec.tsx
new file mode 100644
index 00000000000000..9d84fc9501db7b
--- /dev/null
+++ b/static/app/views/alerts/rules/metric/utils/anomalyChart.spec.tsx
@@ -0,0 +1,108 @@
+import {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart';
+import {type Anomaly, AnomalyType} from 'sentry/views/alerts/types';
+
+const anomaly: Anomaly['anomaly'] = {anomaly_type: AnomalyType.NONE, anomaly_score: 0};
+const anomaly_high: Anomaly['anomaly'] = {
+ anomaly_type: AnomalyType.HIGH_CONFIDENCE,
+ anomaly_score: 2,
+};
+const anomaly_low: Anomaly['anomaly'] = {
+ anomaly_type: AnomalyType.LOW_CONFIDENCE,
+ anomaly_score: 1,
+};
+
+describe('anomalyChart', () => {
+ it('should return an empty array for empty anomalies', () => {
+ const input: Anomaly[] = [];
+ const output = [];
+ expect(getAnomalyMarkerSeries(input)).toEqual(output);
+ });
+
+ it('should not create anomaly values', () => {
+ const input: Anomaly[] = [
+ {
+ anomaly,
+ timestamp: d(-3),
+ value: 1,
+ },
+ {
+ anomaly,
+ timestamp: d(-2),
+ value: 1,
+ },
+ ];
+
+ expect(getAnomalyMarkerSeries(input)).toHaveLength(1);
+ });
+
+ it('should create two anomaly areas', () => {
+ const input: Anomaly[] = [
+ {
+ anomaly: anomaly_high,
+ timestamp: d(-3),
+ value: 1,
+ },
+ {
+ anomaly: anomaly_high,
+ timestamp: d(-2),
+ value: 1,
+ },
+ {
+ anomaly,
+ timestamp: d(-1),
+ value: 0,
+ },
+ {
+ anomaly,
+ timestamp: d(-1),
+ value: 0,
+ },
+ ];
+
+ expect(getAnomalyMarkerSeries(input)).toHaveLength(2);
+ });
+
+ it('should create three anomaly areas', () => {
+ const input: Anomaly[] = [
+ {
+ anomaly: anomaly_high,
+ timestamp: d(-3),
+ value: 1,
+ },
+ {
+ anomaly: anomaly_high,
+ timestamp: d(-2),
+ value: 1,
+ },
+ {
+ anomaly,
+ timestamp: d(-1),
+ value: 0,
+ },
+ {
+ anomaly,
+ timestamp: d(-1),
+ value: 0,
+ },
+ {
+ anomaly: anomaly_low,
+ timestamp: d(1),
+ value: 2,
+ },
+ {
+ anomaly: anomaly_low,
+ timestamp: d(2),
+ value: 2,
+ },
+ ];
+
+ expect(getAnomalyMarkerSeries(input)).toHaveLength(3);
+ });
+});
+
+function d(offset: number) {
+ const value = new Date();
+ value.setHours(12);
+ value.setDate(value.getDate() + offset);
+ return value.valueOf();
+}
diff --git a/static/app/views/alerts/rules/metric/utils/anomalyChart.tsx b/static/app/views/alerts/rules/metric/utils/anomalyChart.tsx
new file mode 100644
index 00000000000000..0f98c9cb7c0286
--- /dev/null
+++ b/static/app/views/alerts/rules/metric/utils/anomalyChart.tsx
@@ -0,0 +1,162 @@
+import type {MarkAreaComponentOption} from 'echarts';
+import moment from 'moment-timezone';
+
+import type {AreaChartSeries} from 'sentry/components/charts/areaChart';
+import MarkLine from 'sentry/components/charts/components/markLine';
+import ConfigStore from 'sentry/stores/configStore';
+import {lightTheme as theme} from 'sentry/utils/theme';
+import type {Anomaly} from 'sentry/views/alerts/types';
+import {AnomalyType} from 'sentry/views/alerts/types';
+
+export interface AnomalyMarkerSeriesOptions {
+ endDate?: Date;
+ startDate?: Date;
+}
+
+export function getAnomalyMarkerSeries(
+ anomalies: Anomaly[],
+ opts: AnomalyMarkerSeriesOptions = {}
+): AreaChartSeries[] {
+ const series: AreaChartSeries[] = [];
+ if (anomalies.length === 0) {
+ return series;
+ }
+ const {startDate, endDate} = opts;
+ const filterPredicate = (anomaly: Anomaly): boolean => {
+ const timestamp = new Date(anomaly.timestamp).getTime();
+ if (startDate && endDate) {
+ return startDate.getTime() < timestamp && timestamp < endDate.getTime();
+ }
+ if (startDate) {
+ return startDate.getTime() < timestamp;
+ }
+ if (endDate) {
+ return timestamp < endDate.getTime();
+ }
+ return true;
+ };
+ const anomalyBlocks: MarkAreaComponentOption['data'] = [];
+ let start: string | undefined;
+ let end: string | undefined;
+
+ anomalies
+ .filter(item => filterPredicate(item))
+ .forEach(item => {
+ const {anomaly, timestamp} = item;
+
+ if (
+ [AnomalyType.HIGH_CONFIDENCE, AnomalyType.LOW_CONFIDENCE].includes(
+ anomaly.anomaly_type
+ )
+ ) {
+ if (!start) {
+ // If this is the start of an anomaly, set start
+ start = getDateForTimestamp(timestamp).toISOString();
+ }
+ // as long as we have an valid anomaly type - continue tracking until we've hit the end
+ end = getDateForTimestamp(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
+ // Create a marker line for the start of the anomaly
+ series.push(createAnomalyMarkerSeries(theme.purple300, start));
+ anomalyBlocks.push([
+ {
+ 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,
+ },
+ });
+
+ return series;
+}
+
+function createAnomalyMarkerSeries(
+ lineColor: string,
+ timestamp: string
+): AreaChartSeries {
+ const formatter = ({value}: any) => {
+ const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
+ return [
+ ``,
+ ``,
+ '',
+ ].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,
+ },
+ };
+}
+
+function getDateForTimestamp(timestamp: string | number): Date {
+ return new Date(typeof timestamp === 'string' ? timestamp : timestamp * 1000);
+}
+
+function formatTooltipDate(date: moment.MomentInput, format: string): string {
+ const {
+ options: {timezone},
+ } = ConfigStore.get('user');
+ return moment.tz(date, timezone).format(format);
+}
diff --git a/static/app/views/alerts/types.tsx b/static/app/views/alerts/types.tsx
index 6e0a6f8c8854e0..ed315d32f1355c 100644
--- a/static/app/views/alerts/types.tsx
+++ b/static/app/views/alerts/types.tsx
@@ -113,17 +113,15 @@ 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;
+ anomaly: {anomaly_score: number; anomaly_type: AnomalyType};
+ timestamp: string | number;
value: number;
};
-export const AnomalyType = {
- high: 'anomaly_higher_confidence',
- low: 'anomaly_lower_confidence',
- none: 'none',
- noData: 'no_data',
-};
+export enum AnomalyType {
+ HIGH_CONFIDENCE = 'anomaly_higher_confidence',
+ LOW_CONFIDENCE = 'anomaly_lower_confidence',
+ NONE = 'none',
+ NO_DATA = 'no_data',
+}