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 [ - `
`, - `
Anomaly Detected
`, - ``, - '
', - ].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 [ + `
`, + `
Anomaly Detected
`, + ``, + '
', + ].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', +}