diff --git a/static/app/components/charts/eventsRequest.tsx b/static/app/components/charts/eventsRequest.tsx index 6843c65bb59893..06c2879733d830 100644 --- a/static/app/components/charts/eventsRequest.tsx +++ b/static/app/components/charts/eventsRequest.tsx @@ -57,6 +57,7 @@ type LoadingStatus = { // Can hold additional data from the root an events stat object (eg. start, end, order, isMetricsData). interface AdditionalSeriesInfo { + isExtrapolatedData?: boolean; isMetricsData?: boolean; } @@ -196,6 +197,10 @@ type EventsRequestPartialProps = { * A unique name for what's triggering this request, see organization_events_stats for an allowlist */ referrer?: string; + /** + * Sample rate used for data extrapolation in OnDemandMetricsRequest + */ + sampleRate?: number; /** * Should loading be shown. */ @@ -480,7 +485,7 @@ class EventsRequest extends PureComponent ({ + doEventsRequest: jest.fn(), +})); + +describe('OnDemandMetricRequest', function () { + const organization = TestStubs.Organization(); + const mock = jest.fn(() => null); + + const DEFAULTS = { + api: new MockApiClient(), + period: '24h', + organization, + includePrevious: false, + interval: '24h', + limit: 30, + query: 'transaction.duration:>1', + children: () => null, + partial: false, + includeTransformedData: true, + sampleRate: SAMPLE_RATE, + }; + + describe('with props changes', function () { + beforeAll(function () { + (doEventsRequest as jest.Mock).mockImplementation(() => + Promise.resolve({ + isMetricsData: true, + data: [], + }) + ); + }); + + it('makes requests', async function () { + render({mock}); + expect(mock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + loading: true, + }) + ); + + await waitFor(() => + expect(mock).toHaveBeenLastCalledWith( + expect.objectContaining({ + loading: false, + seriesAdditionalInfo: { + current: { + isExtrapolatedData: true, + isMetricsData: false, + }, + }, + timeseriesData: [ + { + seriesName: expect.anything(), + data: [], + }, + ], + originalTimeseriesData: [], + }) + ) + ); + + expect(doEventsRequest).toHaveBeenCalledTimes(2); + }); + + it('makes a new request if projects prop changes', function () { + const {rerender} = render( + {mock} + ); + doEventsRequest as jest.Mock; + + rerender( + + {mock} + + ); + + expect(doEventsRequest).toHaveBeenCalledTimes(2); + expect(doEventsRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + project: [123], + useOnDemandMetrics: true, + }) + ); + }); + + it('makes a new request if environments prop changes', function () { + const {rerender} = render( + {mock} + ); + doEventsRequest as jest.Mock; + + rerender( + + {mock} + + ); + + expect(doEventsRequest).toHaveBeenCalledTimes(2); + expect(doEventsRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + environment: ['dev'], + useOnDemandMetrics: true, + }) + ); + }); + + it('makes a new request if period prop changes', function () { + const {rerender} = render( + {mock} + ); + doEventsRequest as jest.Mock; + + rerender( + + {mock} + + ); + + expect(doEventsRequest).toHaveBeenCalledTimes(2); + expect(doEventsRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + period: '7d', + useOnDemandMetrics: true, + }) + ); + }); + }); + + describe('transforms', function () { + beforeEach(function () { + (doEventsRequest as jest.Mock).mockClear(); + }); + + it('applies sample rate to indexed if there is no metrics data`', async function () { + (doEventsRequest as jest.Mock) + .mockImplementation(() => + Promise.resolve({ + isMetricsData: true, + data: [], + }) + ) + .mockImplementation(() => + Promise.resolve({ + isMetricsData: false, + data: [[new Date(), [COUNT_OBJ]]], + }) + ); + + render({mock}); + + expect(mock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + loading: true, + }) + ); + + await waitFor(() => + expect(mock).toHaveBeenLastCalledWith( + expect.objectContaining({ + loading: false, + seriesAdditionalInfo: { + current: { + isExtrapolatedData: true, + isMetricsData: false, + }, + }, + timeseriesData: [ + { + seriesName: expect.anything(), + data: [ + expect.objectContaining({ + name: expect.any(Number), + value: COUNT_OBJ_SCALED.count, + }), + ], + }, + ], + }) + ) + ); + + expect(doEventsRequest).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/static/app/components/charts/onDemandMetricRequest.tsx b/static/app/components/charts/onDemandMetricRequest.tsx new file mode 100644 index 00000000000000..30a032efe6dcfe --- /dev/null +++ b/static/app/components/charts/onDemandMetricRequest.tsx @@ -0,0 +1,121 @@ +import {doEventsRequest} from 'sentry/actionCreators/events'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import EventsRequest from 'sentry/components/charts/eventsRequest'; +import {t} from 'sentry/locale'; +import {EventsStats, MultiSeriesEventsStats} from 'sentry/types'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; + +function numOfEvents(timeseriesData) { + return timeseriesData.data.reduce((acc, item) => { + const count = item[1][0].count; + return acc + count; + }, 0); +} + +function applySampleRate(timeseriesData, sampleRate = 1) { + const scaledData = timeseriesData.data.map(([timestamp, value]) => { + return [timestamp, value.map(item => ({count: Math.round(item.count / sampleRate)}))]; + }); + + return { + ...timeseriesData, + isExtrapolatedData: true, + isMetricsData: false, + data: scaledData, + }; +} + +export class OnDemandMetricRequest extends EventsRequest { + fetchMetricsData = async () => { + const {api, ...props} = this.props; + + try { + const timeseriesData = await doEventsRequest(api, { + ...props, + useOnDemandMetrics: true, + }); + + return timeseriesData; + } catch { + return { + data: [], + isMetricsData: false, + }; + } + }; + + fetchIndexedData = async () => { + const {api, sampleRate, ...props} = this.props; + + const timeseriesData = await doEventsRequest(api, { + ...props, + useOnDemandMetrics: false, + queryExtras: {dataset: DiscoverDatasets.DISCOVER}, + }); + + return applySampleRate(timeseriesData, sampleRate); + }; + + fetchData = async () => { + const {api, confirmedQuery, onError, expired, name, hideError, ...props} = this.props; + let timeseriesData: EventsStats | MultiSeriesEventsStats | null = null; + + if (confirmedQuery === false) { + return; + } + this.setState(state => ({ + reloading: state.timeseriesData !== null, + errored: false, + errorMessage: undefined, + })); + let errorMessage; + if (expired) { + errorMessage = t( + '%s has an invalid date range. Please try a more recent date range.', + name + ); + addErrorMessage(errorMessage, {append: true}); + this.setState({ + errored: true, + errorMessage, + }); + } else { + try { + api.clear(); + + timeseriesData = await this.fetchMetricsData(); + + const fallbackToIndexed = + !timeseriesData.isMetricsData || numOfEvents(timeseriesData) === 0; + + if (fallbackToIndexed) { + timeseriesData = await this.fetchIndexedData(); + } + } catch (resp) { + if (resp && resp.responseJSON && resp.responseJSON.detail) { + errorMessage = resp.responseJSON.detail; + } else { + errorMessage = t('Error loading chart data'); + } + if (!hideError) { + addErrorMessage(errorMessage); + } + onError?.(errorMessage); + this.setState({ + errored: true, + errorMessage, + }); + } + + this.setState({ + reloading: false, + timeseriesData, + fetchedWithPrevious: props.includePrevious, + }); + } + + if (props.dataLoadedCallback) { + props.dataLoadedCallback(timeseriesData); + } + }; +} diff --git a/static/app/types/echarts.tsx b/static/app/types/echarts.tsx index e4f92305f0863e..70a484439e29d6 100644 --- a/static/app/types/echarts.tsx +++ b/static/app/types/echarts.tsx @@ -1,4 +1,9 @@ -import type {AxisPointerComponentOption, ECharts, LineSeriesOption} from 'echarts'; +import type { + AxisPointerComponentOption, + ECharts, + LineSeriesOption, + PatternObject, +} from 'echarts'; import type ReactEchartsCore from 'echarts-for-react/lib/core'; export type SeriesDataUnit = { @@ -15,7 +20,7 @@ export type Series = { data: SeriesDataUnit[]; seriesName: string; areaStyle?: { - color: string; + color: string | PatternObject; opacity: number; }; color?: string; diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index ff9fb7fa8aab8f..989ed4ddfdf87d 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -251,6 +251,7 @@ export type EventsStatsData = [number, {count: number; comparisonCount?: number} export type EventsStats = { data: EventsStatsData; end?: number; + isExtrapolatedData?: boolean; isMetricsData?: boolean; meta?: { fields: Record; diff --git a/static/app/views/alerts/rules/metric/create.spec.tsx b/static/app/views/alerts/rules/metric/create.spec.tsx index 17ddb480d10b80..067674f40f1a9c 100644 --- a/static/app/views/alerts/rules/metric/create.spec.tsx +++ b/static/app/views/alerts/rules/metric/create.spec.tsx @@ -66,7 +66,6 @@ describe('Incident Rules Create', function () { statsPeriod: '10000m', yAxis: 'count()', referrer: 'api.organization-event-stats', - useOnDemandMetrics: false, }, }) ); diff --git a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx index 38b58cbdc392aa..49d076a1bf6066 100644 --- a/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx +++ b/static/app/views/alerts/rules/metric/ruleConditionsForm.tsx @@ -7,6 +7,7 @@ import pick from 'lodash/pick'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; +import Alert from 'sentry/components/alert'; import SearchBar from 'sentry/components/events/searchBar'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import SelectField from 'sentry/components/forms/fields/selectField'; @@ -16,6 +17,7 @@ import ListItem from 'sentry/components/list/listItem'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import {SearchInvalidTag} from 'sentry/components/smartSearchBar/searchInvalidTag'; +import {IconInfo} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Environment, Organization, Project, SelectValue} from 'sentry/types'; @@ -70,6 +72,7 @@ type Props = { allowChangeEventTypes?: boolean; comparisonDelta?: number; disableProjectSelector?: boolean; + isExtrapolatedChartData?: boolean; loadingProjects?: boolean; }; @@ -364,8 +367,14 @@ class RuleConditionsForm extends PureComponent { } render() { - const {organization, disabled, onFilterSearch, allowChangeEventTypes, dataset} = - this.props; + const { + organization, + disabled, + onFilterSearch, + allowChangeEventTypes, + dataset, + isExtrapolatedChartData, + } = this.props; const {environments} = this.state; const environmentOptions: SelectValue[] = [ @@ -380,6 +389,14 @@ class RuleConditionsForm extends PureComponent { return ( + {isExtrapolatedChartData && ( + + + {t( + 'The chart data is an estimate based on the stored transactions that match the filters specified.' + )} + + )} {this.props.thresholdChart} {this.renderInterval()} @@ -529,8 +546,25 @@ const StyledSearchBar = styled(SearchBar)` `} `; +const OnDemandMetricInfoAlert = styled(Alert)` + border-radius: ${space(0.5)} ${space(0.5)} 0 0; + border: none; + border-bottom: 1px solid ${p => p.theme.blue400}; + margin-bottom: 0; + + & > span { + display: flex; + align-items: center; + } +`; + +const OnDemandMetricInfoIcon = styled(IconInfo)` + color: ${p => p.theme.blue400}; + margin-right: ${space(1.5)}; +`; + const StyledListItem = styled(ListItem)` - margin-bottom: ${space(1)}; + margin-bottom: ${space(0.5)}; font-size: ${p => p.theme.fontSizeExtraLarge}; line-height: 1.3; `; diff --git a/static/app/views/alerts/rules/metric/ruleForm.tsx b/static/app/views/alerts/rules/metric/ruleForm.tsx index 1ac1df89202623..7721d6d93071d5 100644 --- a/static/app/views/alerts/rules/metric/ruleForm.tsx +++ b/static/app/views/alerts/rules/metric/ruleForm.tsx @@ -117,6 +117,7 @@ type State = { triggerErrors: Map; triggers: Trigger[]; comparisonDelta?: number; + isExtrapolatedChartData?: boolean; uuid?: string; } & DeprecatedAsyncComponent['state']; @@ -796,23 +797,23 @@ class RuleFormContainer extends DeprecatedAsyncComponent { } }; + handleTimeSeriesDataFetched = (data: EventsStats | MultiSeriesEventsStats | null) => { + const {isExtrapolatedData} = data ?? {}; + + this.setState({isExtrapolatedChartData: Boolean(isExtrapolatedData)}); + if (!isOnDemandMetricAlert(this.state.dataset, this.state.query)) { + this.handleMEPAlertDataset(data); + } + }; + renderLoading() { return this.renderBody(); } - renderBody() { - const { - organization, - ruleId, - rule, - onSubmitSuccess, - router, - disableProjectSelector, - eventView, - location, - } = this.props; + renderTriggerChart() { + const {organization, ruleId, rule} = this.props; + const { - name, query, project, timeWindow, @@ -820,17 +821,18 @@ class RuleFormContainer extends DeprecatedAsyncComponent { aggregate, environment, thresholdType, - thresholdPeriod, comparisonDelta, comparisonType, resolveThreshold, - loading, eventTypes, dataset, alertType, isQueryValid, + location, } = this.state; + const onDemandMetricsAlert = isOnDemandMetricAlert(dataset, query); + const chartProps = { organization, projects: [project], @@ -840,7 +842,6 @@ class RuleFormContainer extends DeprecatedAsyncComponent { aggregate, dataset, newAlertOrQuery: !ruleId || query !== rule.query, - handleMEPAlertDataset: this.handleMEPAlertDataset, timeWindow, environment, resolveThreshold, @@ -848,11 +849,13 @@ class RuleFormContainer extends DeprecatedAsyncComponent { comparisonDelta, comparisonType, isQueryValid, + isOnDemandMetricAlert: onDemandMetricsAlert, + onDataLoaded: this.handleTimeSeriesDataFetched, }; + const wizardBuilderChart = ( {AlertWizardAlertNames[alertType]} @@ -868,6 +871,40 @@ class RuleFormContainer extends DeprecatedAsyncComponent { /> ); + return wizardBuilderChart; + } + + renderBody() { + const { + organization, + ruleId, + rule, + onSubmitSuccess, + router, + disableProjectSelector, + eventView, + } = this.props; + const { + name, + query, + project, + timeWindow, + triggers, + aggregate, + thresholdType, + thresholdPeriod, + comparisonDelta, + comparisonType, + resolveThreshold, + loading, + eventTypes, + dataset, + alertType, + isExtrapolatedChartData, + } = this.state; + + const wizardBuilderChart = this.renderTriggerChart(); + const triggerForm = (disabled: boolean) => ( { } onTimeWindowChange={value => this.handleFieldChange('timeWindow', value)} disableProjectSelector={disableProjectSelector} + isExtrapolatedChartData={isExtrapolatedChartData} /> {t('Set thresholds')} {thresholdTypeForm(formDisabled)} 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 aacb1fd7c46c4d..383ed0acdac3b9 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 @@ -47,7 +47,7 @@ describe('Incident Rules Create', () => { resolveThreshold={null} thresholdType={AlertRuleThresholdType.BELOW} newAlertOrQuery - handleMEPAlertDataset={() => {}} + onDataLoaded={() => {}} isQueryValid /> ); diff --git a/static/app/views/alerts/rules/metric/triggers/chart/index.tsx b/static/app/views/alerts/rules/metric/triggers/chart/index.tsx index 8266279f3ea4a5..1b0d8e09e6d570 100644 --- a/static/app/views/alerts/rules/metric/triggers/chart/index.tsx +++ b/static/app/views/alerts/rules/metric/triggers/chart/index.tsx @@ -11,6 +11,7 @@ import {Client} from 'sentry/api'; import ErrorPanel from 'sentry/components/charts/errorPanel'; import EventsRequest from 'sentry/components/charts/eventsRequest'; import {LineChartSeries} from 'sentry/components/charts/lineChart'; +import {OnDemandMetricRequest} from 'sentry/components/charts/onDemandMetricRequest'; import OptionSelector from 'sentry/components/charts/optionSelector'; import SessionsRequest from 'sentry/components/charts/sessionsRequest'; import { @@ -22,7 +23,7 @@ import { import LoadingMask from 'sentry/components/loadingMask'; import PanelAlert from 'sentry/components/panels/panelAlert'; import Placeholder from 'sentry/components/placeholder'; -import {IconSettings, IconWarning} from 'sentry/icons'; +import {IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { @@ -63,7 +64,6 @@ type Props = { comparisonType: AlertRuleComparisonType; dataset: MetricRule['dataset']; environment: string | null; - handleMEPAlertDataset: (data: EventsStats | MultiSeriesEventsStats | null) => void; isQueryValid: boolean; location: Location; newAlertOrQuery: boolean; @@ -77,6 +77,7 @@ type Props = { comparisonDelta?: number; header?: React.ReactNode; isOnDemandMetricAlert?: boolean; + onDataLoaded?: (data: EventsStats | MultiSeriesEventsStats | null) => void; }; const TIME_PERIOD_MAP: Record = { @@ -139,6 +140,7 @@ const SESSION_AGGREGATE_TO_HEADING = { }; type State = { + sampleRate: number; statsPeriod: TimePeriod; totalCount: number | null; }; @@ -151,6 +153,7 @@ class TriggersChart extends PureComponent { state: State = { statsPeriod: TimePeriod.SEVEN_DAYS, totalCount: null, + sampleRate: 1, }; componentDidMount() { @@ -171,6 +174,9 @@ class TriggersChart extends PureComponent { !isEqual(prevState.statsPeriod, statsPeriod)) ) { this.fetchTotalCount(); + if (this.props.isOnDemandMetricAlert) { + this.fetchSampleRate(); + } } } @@ -208,6 +214,18 @@ class TriggersChart extends PureComponent { ); } + async fetchSampleRate() { + const {api, organization, projects} = this.props; + try { + const {sampleRate} = await api.requestPromise( + `/api/0/projects/${organization.slug}/${projects[0].slug}/dynamic-sampling/rate/` + ); + this.setState({sampleRate}); + } catch { + this.setState({sampleRate: 1}); + } + } + async fetchTotalCount() { const { api, @@ -257,6 +275,7 @@ class TriggersChart extends PureComponent { isQueryValid, errored, orgFeatures, + seriesAdditionalInfo, }: { isLoading: boolean; isQueryValid: boolean; @@ -268,6 +287,7 @@ class TriggersChart extends PureComponent { errorMessage?: string; errored?: boolean; minutesThresholdToDisplaySeconds?: number; + seriesAdditionalInfo?: Record; }) { const { triggers, @@ -277,7 +297,6 @@ class TriggersChart extends PureComponent { timeWindow, aggregate, comparisonType, - isOnDemandMetricAlert, } = this.props; const {statsPeriod, totalCount} = this.state; const statsPeriodOptions = this.availableTimePeriods[timeWindow]; @@ -287,14 +306,15 @@ class TriggersChart extends PureComponent { ? errored || errorMessage : errored || errorMessage || !isQueryValid; + const isExtrapolatedChartData = + seriesAdditionalInfo?.[timeseriesData[0]?.seriesName]?.isExtrapolatedData; + return ( {header} {isLoading && !error ? ( - ) : isOnDemandMetricAlert ? ( - ) : error ? ( { thresholdType={thresholdType} aggregate={aggregate} minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds} + isExtrapolatedData={isExtrapolatedChartData} /> )} @@ -340,7 +361,6 @@ class TriggersChart extends PureComponent { selected={period} onChange={this.handleStatsPeriodChange} title={t('Display')} - disabled={isOnDemandMetricAlert} /> @@ -359,7 +379,7 @@ class TriggersChart extends PureComponent { aggregate, dataset, newAlertOrQuery, - handleMEPAlertDataset, + onDataLoaded, environment, comparisonDelta, triggers, @@ -380,15 +400,60 @@ class TriggersChart extends PureComponent { newAlertOrQuery, }); - // Currently we don't have anything to show for on-demand metric alerts if (isOnDemandMetricAlert) { - return this.renderChart({ - timeseriesData: [], - isQueryValid: true, - isLoading: false, - isReloading: false, - orgFeatures: organization.features, - }); + return ( + Number(id))} + interval={`${timeWindow}m`} + comparisonDelta={comparisonDelta && comparisonDelta * 60} + period={period} + yAxis={aggregate} + includePrevious={false} + currentSeriesNames={[aggregate]} + partial={false} + queryExtras={queryExtras} + sampleRate={this.state.sampleRate} + dataLoadedCallback={onDataLoaded} + > + {({ + 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, + }); + }} + + ); } return isSessionAggregate(aggregate) ? ( @@ -447,8 +512,7 @@ class TriggersChart extends PureComponent { currentSeriesNames={[aggregate]} partial={false} queryExtras={queryExtras} - dataLoadedCallback={handleMEPAlertDataset} - useOnDemandMetrics={isOnDemandMetricAlert} + dataLoadedCallback={onDataLoaded} > {({ loading, @@ -527,18 +591,3 @@ function ErrorChart({isAllowIndexed, isQueryValid, errorMessage}) { ); } - -function WarningChart() { - return ( - - - {t( - 'Selected filters include advanced conditions, which is a feature that is currently in early access. We will start collecting data for the chart once this alert rule is saved.' - )} - - - - - - ); -} 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 e7e94a97a17c74..f99e55d348665c 100644 --- a/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx +++ b/static/app/views/alerts/rules/metric/triggers/chart/thresholdsChart.tsx @@ -4,7 +4,7 @@ import type {TooltipComponentFormatterCallbackParams} from 'echarts'; import debounce from 'lodash/debounce'; import flatten from 'lodash/flatten'; -import {AreaChart, AreaChartSeries} from 'sentry/components/charts/areaChart'; +import {AreaChart} from 'sentry/components/charts/areaChart'; import Graphic from 'sentry/components/charts/components/graphic'; import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip'; import {LineChartSeries} from 'sentry/components/charts/lineChart'; @@ -15,6 +15,7 @@ import {space} from 'sentry/styles/space'; import {PageFilters} from 'sentry/types'; import {ReactEchartsRef, Series} from 'sentry/types/echarts'; import theme from 'sentry/utils/theme'; +import {extrapolatedAreaStyle} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert'; import { ALERT_CHART_MIN_MAX_BUFFER, alertAxisFormatter, @@ -44,6 +45,7 @@ type Props = DefaultProps & { thresholdType: MetricRule['thresholdType']; triggers: Trigger[]; comparisonSeriesName?: string; + isExtrapolatedData?: boolean; maxValue?: number; minValue?: number; minutesThresholdToDisplaySeconds?: number; @@ -321,12 +323,20 @@ export default class ThresholdsChart extends PureComponent { thresholdType, } = this.props; - const dataWithoutRecentBucket: AreaChartSeries[] = data?.map( - ({data: eventData, ...restOfData}) => ({ + const dataWithoutRecentBucket = data?.map(({data: eventData, ...restOfData}) => { + if (this.props.isExtrapolatedData) { + return { + ...restOfData, + data: eventData.slice(0, -1), + areaStyle: extrapolatedAreaStyle, + }; + } + + return { ...restOfData, data: eventData.slice(0, -1), - }) - ); + }; + }); const comparisonDataWithoutRecentBucket = comparisonData?.map( ({data: eventData, ...restOfData}) => ({ diff --git a/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.spec.tsx b/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.spec.tsx new file mode 100644 index 00000000000000..d0c7662a0ab2fb --- /dev/null +++ b/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.spec.tsx @@ -0,0 +1,43 @@ +import {Dataset} from 'sentry/views/alerts/rules/metric/types'; +import { + hasNonStandardMetricSearchFilters, + isOnDemandMetricAlert, +} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert'; + +describe('isOnDemandMetricAlert', () => { + it('should return true for an alert that contains non standard fields', () => { + const dataset = Dataset.GENERIC_METRICS; + + expect(isOnDemandMetricAlert(dataset, 'transaction.duration:>1')).toBeTruthy(); + expect(isOnDemandMetricAlert(dataset, 'browser.version:>91')).toBeTruthy(); + expect(isOnDemandMetricAlert(dataset, 'geo.region:>US')).toBeTruthy(); + }); + + it('should return false for an alert that has only standard fields', () => { + const dataset = Dataset.GENERIC_METRICS; + + expect(isOnDemandMetricAlert(dataset, 'release:1.0')).toBeFalsy(); + expect(isOnDemandMetricAlert(dataset, 'browser.name:chrome')).toBeFalsy(); + }); + + it('should return false if dataset is not generic_metrics', () => { + const dataset = Dataset.TRANSACTIONS; + + expect(isOnDemandMetricAlert(dataset, 'transaction.duration:>1')).toBeFalsy(); + expect(isOnDemandMetricAlert(dataset, 'browser.version:>91')).toBeFalsy(); + expect(isOnDemandMetricAlert(dataset, 'geo.region:>US')).toBeFalsy(); + }); +}); + +describe('hasNonStandardMetricSearchFilters', () => { + it('should return true for a query that contains non-standard query keys', () => { + expect(hasNonStandardMetricSearchFilters('transaction.duration:>1')).toBeTruthy(); + expect(hasNonStandardMetricSearchFilters('browser.version:>91')).toBeTruthy(); + expect(hasNonStandardMetricSearchFilters('geo.region:>US')).toBeTruthy(); + }); + + it('should return false for an alert that has only standard fields', () => { + expect(hasNonStandardMetricSearchFilters('release:1.0')).toBeFalsy(); + expect(hasNonStandardMetricSearchFilters('browser.name:chrome')).toBeFalsy(); + }); +}); diff --git a/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.tsx b/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.tsx index 6d999ec1ff64d9..3e4c5639b077ce 100644 --- a/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.tsx +++ b/static/app/views/alerts/rules/metric/utils/onDemandMetricAlert.tsx @@ -77,3 +77,16 @@ function getTokenKey(token): SearchFilterKey[] | SearchFilterKey { return null; } + +const EXTRAPOLATED_AREA_STRIPE_IMG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAABkAQMAAACFAjPUAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAZQTFRFpKy5SVlzL3npZAAAAA9JREFUeJxjsD/AMIqIQwBIyGOd43jaDwAAAABJRU5ErkJggg=='; + +export const extrapolatedAreaStyle = { + color: { + repeat: 'repeat', + image: EXTRAPOLATED_AREA_STRIPE_IMG, + rotation: 0.785, + scaleX: 0.5, + }, + opacity: 1.0, +};