diff --git a/static/app/actionCreators/events.tsx b/static/app/actionCreators/events.tsx index 712aa67dabe72c..1a702ac7182007 100644 --- a/static/app/actionCreators/events.tsx +++ b/static/app/actionCreators/events.tsx @@ -41,6 +41,7 @@ type Options = { end?: DateString; environment?: readonly string[]; excludeOther?: boolean; + extrapolationMode?: string; field?: string[]; generatePathname?: (org: OrganizationSummary) => string; includePrevious?: boolean; @@ -108,6 +109,7 @@ export const doEventsRequest = ( includeAllArgs, dataset, sampling, + extrapolationMode, }: EventsStatsOptions ): IncludeAllArgsType extends true ? Promise> @@ -135,6 +137,7 @@ export const doEventsRequest = ( excludeOther: excludeOther ? '1' : undefined, dataset, sampling, + extrapolationMode, }).filter(([, value]) => typeof value !== 'undefined') ); diff --git a/static/app/components/charts/eventsRequest.tsx b/static/app/components/charts/eventsRequest.tsx index 347fd920a71b8c..ab25de5aba0632 100644 --- a/static/app/components/charts/eventsRequest.tsx +++ b/static/app/components/charts/eventsRequest.tsx @@ -150,6 +150,10 @@ type EventsRequestPartialProps = { * Is query out of retention */ expired?: boolean; + /** + * The extrapolation mode to apply to the EAP + */ + extrapolationMode?: string; /** * List of fields to group with when doing a topEvents request. */ diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index a422988a57c630..b65d483fcbbac3 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -9,6 +9,7 @@ import type { AlertRuleThresholdType, Dataset, EventTypes, + ExtrapolationMode, } from 'sentry/views/alerts/rules/metric/types'; import type {UptimeMonitorMode} from 'sentry/views/alerts/rules/uptime/types'; import type {Monitor, MonitorConfig} from 'sentry/views/insights/crons/types'; @@ -27,6 +28,7 @@ export interface SnubaQuery { */ timeWindow: number; environment?: string; + extrapolationMode?: ExtrapolationMode; } /** @@ -179,6 +181,7 @@ interface UpdateSnubaDataSourcePayload { query: string; queryType: number; timeWindow: number; + extrapolationMode?: string; } interface UpdateUptimeDataSourcePayload { diff --git a/static/app/views/alerts/rules/metric/details/index.spec.tsx b/static/app/views/alerts/rules/metric/details/index.spec.tsx index b93820dfcbdd58..8950fa5cb2e6e2 100644 --- a/static/app/views/alerts/rules/metric/details/index.spec.tsx +++ b/static/app/views/alerts/rules/metric/details/index.spec.tsx @@ -10,7 +10,12 @@ import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import ProjectsStore from 'sentry/stores/projectsStore'; import {trackAnalytics} from 'sentry/utils/analytics'; import MetricAlertDetails from 'sentry/views/alerts/rules/metric/details'; -import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; +import { + Dataset, + EventTypes, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; jest.mock('sentry/utils/analytics'); @@ -290,4 +295,106 @@ describe('MetricAlertDetails', () => { 'true' ); }); + + it('uses SERVER_WEIGHTED extrapolation mode when alert has it configured', async () => { + const {organization, routerProps} = initializeOrg(); + const ruleWithExtrapolation = MetricRuleFixture({ + projects: [project.slug], + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + aggregate: 'count()', + query: '', + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/alert-rules/${ruleWithExtrapolation.id}/`, + body: ruleWithExtrapolation, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/incidents/`, + body: [], + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: EventsStatsFixture(), + }); + + render( + , + { + organization, + } + ); + + expect(await screen.findByText(ruleWithExtrapolation.name)).toBeInTheDocument(); + + // Verify events-stats is called with 'serverOnly' extrapolation mode + expect(eventsStatsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'serverOnly', + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + }); + + it('uses NONE extrapolation mode when alert has it configured', async () => { + const {organization, routerProps} = initializeOrg(); + const ruleWithNoExtrapolation = MetricRuleFixture({ + projects: [project.slug], + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + aggregate: 'count()', + query: '', + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.NONE, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/alert-rules/${ruleWithNoExtrapolation.id}/`, + body: ruleWithNoExtrapolation, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/incidents/`, + body: [], + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: EventsStatsFixture(), + }); + + render( + , + { + organization, + } + ); + + expect(await screen.findByText(ruleWithNoExtrapolation.name)).toBeInTheDocument(); + + // Verify events-stats is called with 'none' extrapolation mode + expect(eventsStatsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'none', + sampling: SAMPLING_MODE.HIGH_ACCURACY, + }), + }) + ); + }); }); diff --git a/static/app/views/alerts/rules/metric/details/metricChart.tsx b/static/app/views/alerts/rules/metric/details/metricChart.tsx index 789fc201c3f7c9..a241a919429c8d 100644 --- a/static/app/views/alerts/rules/metric/details/metricChart.tsx +++ b/static/app/views/alerts/rules/metric/details/metricChart.tsx @@ -52,7 +52,11 @@ import useOrganization from 'sentry/utils/useOrganization'; import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants'; import {makeDefaultCta} from 'sentry/views/alerts/rules/metric/metricRulePresets'; import type {MetricRule} from 'sentry/views/alerts/rules/metric/types'; -import {AlertRuleTriggerType, Dataset} from 'sentry/views/alerts/rules/metric/types'; +import { + AlertRuleTriggerType, + Dataset, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; import {isCrashFreeAlert} from 'sentry/views/alerts/rules/metric/utils/isCrashFreeAlert'; import { isEapAlertType, @@ -475,7 +479,9 @@ export default function MetricChart({ referrer: 'api.alerts.alert-rule-chart', samplingMode: rule.dataset === Dataset.EVENTS_ANALYTICS_PLATFORM - ? SAMPLING_MODE.NORMAL + ? rule.extrapolationMode === ExtrapolationMode.NONE + ? SAMPLING_MODE.HIGH_ACCURACY + : SAMPLING_MODE.NORMAL : undefined, }, {enabled: !shouldUseSessionsStats} diff --git a/static/app/views/alerts/rules/metric/duplicate.spec.tsx b/static/app/views/alerts/rules/metric/duplicate.spec.tsx index 876c64efbbd88c..8d4fc1a2d6eb53 100644 --- a/static/app/views/alerts/rules/metric/duplicate.spec.tsx +++ b/static/app/views/alerts/rules/metric/duplicate.spec.tsx @@ -9,7 +9,7 @@ import GlobalModal from 'sentry/components/globalModal'; import MetricRuleDuplicate from './duplicate'; import type {Action} from './types'; -import {AlertRuleTriggerType} from './types'; +import {AlertRuleTriggerType, Dataset, EventTypes, ExtrapolationMode} from './types'; describe('MetricRuleDuplicate', () => { beforeEach(() => { @@ -55,6 +55,14 @@ describe('MetricRuleDuplicate', () => { url: '/organizations/org-slug/members/', body: [MemberFixture()], }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/trace-items/attributes/', + body: [], + }); }); it('renders new alert form with values copied over', async () => { @@ -153,4 +161,68 @@ describe('MetricRuleDuplicate', () => { // Duplicated alert has been called expect(req).toHaveBeenCalled(); }); + + it('duplicates rule with SERVER_WEIGHTED extrapolation mode but creates new rule without it', async () => { + const ruleWithExtrapolation = MetricRuleFixture({ + id: '7', + name: 'Alert Rule with Extrapolation', + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + aggregate: 'count()', + query: '', + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }); + + const {organization, project, routerProps} = initializeOrg({ + organization: { + access: ['alerts:write'], + }, + router: { + params: {}, + location: { + query: { + createFromDuplicate: 'true', + duplicateRuleId: `${ruleWithExtrapolation.id}`, + }, + }, + }, + }); + + const req = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/alert-rules/${ruleWithExtrapolation.id}/`, + body: ruleWithExtrapolation, + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: null, + }); + + render( + + + + + ); + + // Wait for the form to load with duplicated values + expect(await screen.findByTestId('critical-threshold')).toHaveValue('70'); + expect(screen.getByTestId('alert-name')).toHaveValue( + `${ruleWithExtrapolation.name} copy` + ); + + // Verify the original rule was fetched + expect(req).toHaveBeenCalled(); + + // Verify events-stats chart requests do NOT include extrapolation mode + // (duplicated rules are treated as new rules) + expect(eventsStatsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.not.objectContaining({ + extrapolationMode: expect.anything(), + }), + }) + ); + }); }); diff --git a/static/app/views/alerts/rules/metric/edit.spec.tsx b/static/app/views/alerts/rules/metric/edit.spec.tsx index e5bc624a897470..b2a0c3510d7afd 100644 --- a/static/app/views/alerts/rules/metric/edit.spec.tsx +++ b/static/app/views/alerts/rules/metric/edit.spec.tsx @@ -7,7 +7,13 @@ import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {metric} from 'sentry/utils/analytics'; import {MetricRulesEdit} from 'sentry/views/alerts/rules/metric/edit'; -import {AlertRuleTriggerType} from 'sentry/views/alerts/rules/metric/types'; +import { + AlertRuleTriggerType, + Dataset, + EventTypes, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; jest.mock('sentry/utils/analytics', () => ({ metric: { @@ -58,6 +64,14 @@ describe('MetricRulesEdit', () => { url: '/organizations/org-slug/members/', body: [MemberFixture()], }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/trace-items/attributes/', + body: [], + }); }); afterEach(() => { @@ -252,4 +266,154 @@ describe('MetricRulesEdit', () => { await screen.findByText('This alert rule could not be found.') ).toBeInTheDocument(); }); + + it('preserves SERVER_WEIGHTED extrapolation mode when editing and saving', async () => { + const {organization, project} = initializeOrg(); + const ruleWithExtrapolation = MetricRuleFixture({ + id: '5', + name: 'Alert Rule with Extrapolation', + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + aggregate: 'count()', + query: '', + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/alert-rules/${ruleWithExtrapolation.id}/`, + body: ruleWithExtrapolation, + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: null, + }); + + const editRule = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/alert-rules/${ruleWithExtrapolation.id}/`, + method: 'PUT', + body: ruleWithExtrapolation, + }); + + render( + {}} + project={project} + /> + ); + + // Wait for the rule to load + expect(await screen.findByTestId('critical-threshold')).toBeInTheDocument(); + + // Verify events-stats is called with 'serverOnly' extrapolation mode for the chart + expect(eventsStatsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'serverOnly', + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + + // Make a change to the threshold + await userEvent.clear(screen.getByTestId('resolve-threshold')); + await userEvent.type(screen.getByTestId('resolve-threshold'), '40'); + + // Save the rule + await userEvent.click(screen.getByLabelText('Save Rule')); + + // Verify the save request preserves the extrapolation mode + expect(editRule).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }), + method: 'PUT', + }) + ); + }); + + it('preserves NONE extrapolation mode when editing and saving', async () => { + const {organization, project} = initializeOrg(); + const ruleWithNoExtrapolation = MetricRuleFixture({ + id: '6', + name: 'Alert Rule with No Extrapolation', + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + aggregate: 'count()', + query: '', + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.NONE, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/alert-rules/${ruleWithNoExtrapolation.id}/`, + body: ruleWithNoExtrapolation, + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + body: null, + }); + + const editRule = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/alert-rules/${ruleWithNoExtrapolation.id}/`, + method: 'PUT', + body: ruleWithNoExtrapolation, + }); + + render( + {}} + project={project} + /> + ); + + // Wait for the rule to load + expect(await screen.findByTestId('critical-threshold')).toBeInTheDocument(); + + // Verify events-stats is called with 'none' extrapolation mode for the chart + expect(eventsStatsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events-stats/', + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'none', + sampling: SAMPLING_MODE.HIGH_ACCURACY, + }), + }) + ); + + // Make a change to the threshold + await userEvent.clear(screen.getByTestId('resolve-threshold')); + await userEvent.type(screen.getByTestId('resolve-threshold'), '50'); + + // Save the rule + await userEvent.click(screen.getByLabelText('Save Rule')); + + // Verify the save request preserves the extrapolation mode + expect(editRule).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + extrapolationMode: ExtrapolationMode.NONE, + }), + method: 'PUT', + }) + ); + }); }); diff --git a/static/app/views/alerts/rules/metric/ruleForm.tsx b/static/app/views/alerts/rules/metric/ruleForm.tsx index dce917d60d8171..0343507eb694cf 100644 --- a/static/app/views/alerts/rules/metric/ruleForm.tsx +++ b/static/app/views/alerts/rules/metric/ruleForm.tsx @@ -89,6 +89,7 @@ import { import RuleConditionsForm from './ruleConditionsForm'; import type { EventTypes, + ExtrapolationMode, MetricActionTemplate, MetricRule, Trigger, @@ -158,6 +159,7 @@ type State = { chartErrorMessage?: string; comparisonDelta?: number; confidence?: Confidence; + extrapolationMode?: ExtrapolationMode; isExtrapolatedChartData?: boolean; seasonality?: AlertRuleSeasonality; seriesSamplingInfo?: SeriesSamplingInfo; @@ -259,6 +261,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent { metricExtractionRules: null, triggers: triggersClone, resolveThreshold: rule.resolveThreshold, + extrapolationMode: rule.extrapolationMode, sensitivity: rule.sensitivity ?? undefined, seasonality: rule.seasonality ?? undefined, thresholdType: rule.thresholdType, @@ -749,6 +752,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent { sensitivity, seasonality, comparisonType, + extrapolationMode, } = this.state; // Remove empty warning trigger const sanitizedTriggers = triggers.filter( @@ -813,6 +817,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent { sensitivity: sensitivity ?? null, seasonality: seasonality ?? null, detectionType, + extrapolationMode: this.isDuplicateRule ? undefined : extrapolationMode, }, { duplicateRule: this.isDuplicateRule ? 'true' : 'false', @@ -1206,6 +1211,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent { chartErrorMessage, confidence, seriesSamplingInfo, + extrapolationMode, } = this.state; const traceItemType = getTraceItemTypeForDatasetAndEventType(dataset, eventTypes); @@ -1256,6 +1262,7 @@ class RuleFormContainer extends DeprecatedAsyncComponent { confidence, seriesSamplingInfo, traceItemType: traceItemType ?? undefined, + extrapolationMode, }; let formattedQuery = `event.type:${eventTypes?.join(',')}`; 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 914fd5a1ed82d1..59322c155f56e6 100644 --- a/static/app/views/alerts/rules/metric/triggers/chart/index.tsx +++ b/static/app/views/alerts/rules/metric/triggers/chart/index.tsx @@ -46,13 +46,16 @@ import { import {capitalize} from 'sentry/utils/string/capitalize'; import withApi from 'sentry/utils/withApi'; import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants'; -import type {MetricRule, Trigger} from 'sentry/views/alerts/rules/metric/types'; import { AlertRuleComparisonType, Dataset, + EAP_EXTRAPOLATION_MODE_MAP, + ExtrapolationMode, SessionsAggregate, TimePeriod, TimeWindow, + type MetricRule, + type Trigger, } from 'sentry/views/alerts/rules/metric/types'; import type {SeriesSamplingInfo} from 'sentry/views/alerts/rules/metric/utils/determineSeriesSampleCount'; import {getMetricDatasetQueryExtras} from 'sentry/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras'; @@ -97,6 +100,7 @@ type Props = { anomalies?: Anomaly[]; comparisonDelta?: number; confidence?: Confidence; + extrapolationMode?: ExtrapolationMode; formattedAggregate?: string; header?: React.ReactNode; includeHistorical?: boolean; @@ -427,6 +431,7 @@ class TriggersChart extends PureComponent { isQueryValid, isOnDemandMetricAlert, traceItemType, + extrapolationMode, } = this.props; const period = this.getStatsPeriod()!; @@ -463,6 +468,13 @@ class TriggersChart extends PureComponent { partial: false, limit: 15, children: noop, + extrapolationMode: extrapolationMode + ? EAP_EXTRAPOLATION_MODE_MAP[extrapolationMode] + : undefined, + sampling: + extrapolationMode === ExtrapolationMode.NONE + ? SAMPLING_MODE.HIGH_ACCURACY + : SAMPLING_MODE.NORMAL, }; return ( @@ -589,6 +601,17 @@ class TriggersChart extends PureComponent { includePrevious: false, currentSeriesNames: [formattedAggregate || aggregate], partial: false, + sampling: + dataset === Dataset.EVENTS_ANALYTICS_PLATFORM && + this.props.traceItemType === TraceItemDataset.SPANS + ? extrapolationMode === ExtrapolationMode.NONE + ? SAMPLING_MODE.HIGH_ACCURACY + : SAMPLING_MODE.NORMAL + : undefined, + + extrapolationMode: extrapolationMode + ? EAP_EXTRAPOLATION_MODE_MAP[extrapolationMode] + : undefined, }; return ( @@ -615,17 +638,7 @@ class TriggersChart extends PureComponent { {noop} ) : null} - + {({ loading, errored, diff --git a/static/app/views/alerts/rules/metric/types.tsx b/static/app/views/alerts/rules/metric/types.tsx index b31a496f366c9d..ddda03eabe855a 100644 --- a/static/app/views/alerts/rules/metric/types.tsx +++ b/static/app/views/alerts/rules/metric/types.tsx @@ -39,6 +39,20 @@ export enum Dataset { EVENTS_ANALYTICS_PLATFORM = 'events_analytics_platform', } +export enum ExtrapolationMode { + CLIENT_AND_SERVER_WEIGHTED = 'client_and_server_weighted', + SERVER_WEIGHTED = 'server_weighted', + UNKNOWN = 'unknown', + NONE = 'none', +} + +export const EAP_EXTRAPOLATION_MODE_MAP = { + [ExtrapolationMode.CLIENT_AND_SERVER_WEIGHTED]: 'sampleWeighted', + [ExtrapolationMode.SERVER_WEIGHTED]: 'serverOnly', + [ExtrapolationMode.NONE]: 'none', + [ExtrapolationMode.UNKNOWN]: 'sampleWeighted', +}; + export enum EventTypes { DEFAULT = 'default', ERROR = 'error', @@ -133,6 +147,7 @@ export interface SavedMetricRule extends UnsavedMetricRule { status: number; createdBy?: {email: string; id: number; name: string} | null; errors?: Array<{detail: string}>; + extrapolationMode?: ExtrapolationMode; /** * Returned with the expand=latestIncident query parameter */ diff --git a/static/app/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras.tsx b/static/app/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras.tsx index 6dd8318fb6ce44..50114ab0102099 100644 --- a/static/app/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras.tsx +++ b/static/app/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras.tsx @@ -4,7 +4,11 @@ import type {Organization} from 'sentry/types/organization'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {hasOnDemandMetricAlertFeature} from 'sentry/utils/onDemandMetrics/features'; import {decodeScalar} from 'sentry/utils/queryString'; -import {Dataset, type MetricRule} from 'sentry/views/alerts/rules/metric/types'; +import { + Dataset, + ExtrapolationMode, + type MetricRule, +} from 'sentry/views/alerts/rules/metric/types'; import {shouldUseErrorsDiscoverDataset} from 'sentry/views/alerts/rules/utils'; import {getDiscoverDataset} from 'sentry/views/alerts/wizard/options'; import {TraceItemDataset} from 'sentry/views/explore/types'; @@ -21,6 +25,7 @@ export function getMetricDatasetQueryExtras({ dataset: MetricRule['dataset']; newAlertOrQuery: boolean; organization: Organization; + extrapolationMode?: ExtrapolationMode; location?: Location; query?: string; traceItemType?: TraceItemDataset | null; diff --git a/static/app/views/detectors/components/details/metric/chart.tsx b/static/app/views/detectors/components/details/metric/chart.tsx index f2a9529c19a212..580de6a05fb9b4 100644 --- a/static/app/views/detectors/components/details/metric/chart.tsx +++ b/static/app/views/detectors/components/details/metric/chart.tsx @@ -164,6 +164,7 @@ export function useMetricDetectorChart({ const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({ detectorDataset: dataset, dataset: snubaQuery.dataset, + extrapolationMode: snubaQuery.extrapolationMode, aggregate: datasetConfig.fromApiAggregate(snubaQuery.aggregate), interval: snubaQuery.timeWindow, query: snubaQuery.query, diff --git a/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx b/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx index 7d676acd1fc747..a06ce86fce1eb3 100644 --- a/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx +++ b/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx @@ -23,6 +23,7 @@ import { AlertRuleThresholdType, Dataset, EventTypes, + ExtrapolationMode, } from 'sentry/views/alerts/rules/metric/types'; import {getBackendDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData'; import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; @@ -133,6 +134,7 @@ interface MetricDetectorChartProps { * Used in anomaly detection */ thresholdType: AlertRuleThresholdType | undefined; + extrapolationMode?: ExtrapolationMode | undefined; } export function MetricDetectorChart({ @@ -149,6 +151,7 @@ export function MetricDetectorChart({ comparisonDelta, sensitivity, thresholdType, + extrapolationMode, }: MetricDetectorChartProps) { const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} = useTimePeriodSelection({ @@ -167,6 +170,7 @@ export function MetricDetectorChart({ statsPeriod: selectedTimePeriod, comparisonDelta, eventTypes, + extrapolationMode, }); const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} = diff --git a/static/app/views/detectors/components/forms/metric/metricFormData.tsx b/static/app/views/detectors/components/forms/metric/metricFormData.tsx index bc622b7dfff406..74676447c3931e 100644 --- a/static/app/views/detectors/components/forms/metric/metricFormData.tsx +++ b/static/app/views/detectors/components/forms/metric/metricFormData.tsx @@ -19,6 +19,7 @@ import { AlertRuleSensitivity, AlertRuleThresholdType, Dataset, + ExtrapolationMode, } from 'sentry/views/alerts/rules/metric/types'; import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig'; import {getDetectorDataset} from 'sentry/views/detectors/datasetConfig/getDetectorDataset'; @@ -84,6 +85,7 @@ interface SnubaQueryFormData { environment: string; interval: number; query: string; + extrapolationMode?: ExtrapolationMode; } export interface MetricDetectorFormData @@ -120,6 +122,7 @@ export const METRIC_DETECTOR_FORM_FIELDS = { interval: 'interval', query: 'query', name: 'name', + extrapolationMode: 'extrapolationMode', // Priority level fields initialPriorityLevel: 'initialPriorityLevel', @@ -182,6 +185,7 @@ interface NewDataSource { query: string; queryType: number; timeWindow: number; + extrapolationMode?: string; } function createAnomalyDetectionCondition( @@ -309,6 +313,7 @@ function createDataSource(data: MetricDetectorFormData): NewDataSource { return { queryType: getQueryType(data.dataset), dataset: getBackendDataset(data.dataset), + extrapolationMode: data.extrapolationMode, query, aggregate: datasetConfig.toApiAggregate(data.aggregateFunction), timeWindow: data.interval, @@ -490,6 +495,7 @@ export function metricSavedDetectorToFormData( owner: detector.owner ? `${detector.owner?.type}:${detector.owner?.id}` : '', description: detector.description || null, query: datasetConfig.toSnubaQueryString(snubaQuery), + extrapolationMode: snubaQuery.extrapolationMode, aggregateFunction: datasetConfig.fromApiAggregate(snubaQuery?.aggregate || '') || DEFAULT_THRESHOLD_METRIC_FORM_DATA.aggregateFunction, diff --git a/static/app/views/detectors/components/forms/metric/previewChart.tsx b/static/app/views/detectors/components/forms/metric/previewChart.tsx index 1c0c54a9534fd8..6ada85d48c84e3 100644 --- a/static/app/views/detectors/components/forms/metric/previewChart.tsx +++ b/static/app/views/detectors/components/forms/metric/previewChart.tsx @@ -19,6 +19,9 @@ export function MetricDetectorPreviewChart() { const rawQuery = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.query); const environment = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.environment); const projectId = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.projectId); + const extrapolationMode = useMetricDetectorFormField( + METRIC_DETECTOR_FORM_FIELDS.extrapolationMode + ); // Threshold-related form fields const highThreshold = useMetricDetectorFormField( @@ -88,6 +91,7 @@ export function MetricDetectorPreviewChart() { comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined} sensitivity={sensitivity} thresholdType={thresholdType} + extrapolationMode={extrapolationMode} /> ); } diff --git a/static/app/views/detectors/datasetConfig/base.tsx b/static/app/views/detectors/datasetConfig/base.tsx index c9ecc7a99d6ae4..825de780272778 100644 --- a/static/app/views/detectors/datasetConfig/base.tsx +++ b/static/app/views/detectors/datasetConfig/base.tsx @@ -10,7 +10,11 @@ import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/ import type {QueryFieldValue} from 'sentry/utils/discover/fields'; import type {DiscoverDatasets} from 'sentry/utils/discover/types'; import type {ApiQueryKey} from 'sentry/utils/queryClient'; -import type {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; +import type { + Dataset, + EventTypes, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; import type { MetricDetectorInterval, MetricDetectorTimePeriod, @@ -56,6 +60,7 @@ interface DetectorSeriesQueryOptions { extra?: { useOnDemandMetrics: 'true'; }; + extrapolationMode?: ExtrapolationMode; start?: string | null; /** * Relative time period for the query. Example: '7d'. diff --git a/static/app/views/detectors/datasetConfig/utils/discoverSeries.tsx b/static/app/views/detectors/datasetConfig/utils/discoverSeries.tsx index 55bf747d10a3d4..8d3addda9befbf 100644 --- a/static/app/views/detectors/datasetConfig/utils/discoverSeries.tsx +++ b/static/app/views/detectors/datasetConfig/utils/discoverSeries.tsx @@ -3,6 +3,11 @@ import type {EventsStats, Organization} from 'sentry/types/organization'; import type {DiscoverDatasets} from 'sentry/utils/discover/types'; import getDuration from 'sentry/utils/duration/getDuration'; import type {ApiQueryKey} from 'sentry/utils/queryClient'; +import { + EAP_EXTRAPOLATION_MODE_MAP, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; /** * Transform EventsStats API response into Series format for AreaChart @@ -87,6 +92,7 @@ interface DiscoverSeriesQueryOptions { extra?: { useOnDemandMetrics: 'true'; }; + extrapolationMode?: ExtrapolationMode; start?: string | null; /** * Relative time period for the query. Example: '7d'. @@ -106,6 +112,7 @@ export function getDiscoverSeriesQueryOptions({ comparisonDelta, start, end, + extrapolationMode, }: DiscoverSeriesQueryOptions): ApiQueryKey { return [ `/organizations/${organization.slug}/events-stats/`, @@ -121,6 +128,13 @@ export function getDiscoverSeriesQueryOptions({ statsPeriod, start, end, + sampling: + extrapolationMode === ExtrapolationMode.NONE + ? SAMPLING_MODE.HIGH_ACCURACY + : SAMPLING_MODE.NORMAL, + extrapolationMode: extrapolationMode + ? EAP_EXTRAPOLATION_MODE_MAP[extrapolationMode] + : undefined, ...(environment && {environment: [environment]}), ...(query && {query}), ...(comparisonDelta && {comparisonDelta}), diff --git a/static/app/views/detectors/detail.spec.tsx b/static/app/views/detectors/detail.spec.tsx index 73fb4d756730f0..0e8fd9587e1e2c 100644 --- a/static/app/views/detectors/detail.spec.tsx +++ b/static/app/views/detectors/detail.spec.tsx @@ -19,8 +19,14 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; +import { + Dataset, + EventTypes, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; import {CheckStatus} from 'sentry/views/alerts/rules/uptime/types'; import DetectorDetails from 'sentry/views/detectors/detail'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; describe('DetectorDetails', () => { const organization = OrganizationFixture({features: ['workflow-engine-ui']}); @@ -374,5 +380,68 @@ describe('DetectorDetails', () => { // Connected automation expect(await screen.findByText('Automation 1')).toBeInTheDocument(); }); + + it('uses serverOnly extrapolation mode when detector has it configured', async () => { + const spanDetectorWithExtrapolation = MetricDetectorFixture({ + id: '1', + projectId: project.id, + dataSources: [ + SnubaQueryDataSourceFixture({ + queryObj: { + id: '1', + status: 1, + subscription: '1', + snubaQuery: { + aggregate: 'count()', + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + id: '', + query: '', + timeWindow: 3600, + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }, + }, + }), + ], + owner: ActorFixture({id: ownerTeam.id, name: ownerTeam.slug, type: 'team'}), + workflowIds: ['1', '2'], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${spanDetectorWithExtrapolation.id}/`, + body: spanDetectorWithExtrapolation, + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events-stats/`, + body: { + data: [ + [1543449600, [20, 12]], + [1543449601, [10, 5]], + ], + }, + }); + + render(, { + organization, + initialRouterConfig, + }); + + expect( + await screen.findByRole('heading', {name: spanDetectorWithExtrapolation.name}) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(eventsStatsRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/events-stats/`, + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'serverOnly', + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + }); + }); }); }); diff --git a/static/app/views/detectors/edit.spec.tsx b/static/app/views/detectors/edit.spec.tsx index 36650451d16e32..1568e21f8e7f89 100644 --- a/static/app/views/detectors/edit.spec.tsx +++ b/static/app/views/detectors/edit.spec.tsx @@ -29,9 +29,11 @@ import { AlertRuleThresholdType, Dataset, EventTypes, + ExtrapolationMode, } from 'sentry/views/alerts/rules/metric/types'; import {SnubaQueryType} from 'sentry/views/detectors/components/forms/metric/metricFormData'; import DetectorEdit from 'sentry/views/detectors/edit'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; describe('DetectorEdit', () => { const organization = OrganizationFixture({ @@ -927,5 +929,99 @@ describe('DetectorEdit', () => { // Aggregate selector should be disabled expect(screen.getByRole('button', {name: 'p75'})).toBeDisabled(); }); + + it('preserves SERVER_WEIGHTED extrapolation mode when editing and saving', async () => { + const spanDetectorWithExtrapolation = MetricDetectorFixture({ + id: '1', + name: 'Span Detector with Extrapolation', + projectId: project.id, + dataSources: [ + SnubaQueryDataSourceFixture({ + queryObj: { + id: '1', + status: 1, + subscription: '1', + snubaQuery: { + aggregate: 'count()', + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + id: '', + query: '', + timeWindow: 3600, + eventTypes: [EventTypes.TRACE_ITEM_SPAN], + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }, + }, + }), + ], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${spanDetectorWithExtrapolation.id}/`, + body: spanDetectorWithExtrapolation, + }); + + const eventsStatsRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events-stats/`, + body: {data: []}, + }); + + const updateRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${spanDetectorWithExtrapolation.id}/`, + method: 'PUT', + body: spanDetectorWithExtrapolation, + }); + + render(, { + organization, + initialRouterConfig: { + route: '/organizations/:orgId/monitors/:detectorId/edit/', + location: { + pathname: `/organizations/${organization.slug}/monitors/${spanDetectorWithExtrapolation.id}/edit/`, + }, + }, + }); + + expect( + await screen.findByRole('link', {name: 'Span Detector with Extrapolation'}) + ).toBeInTheDocument(); + + // Verify events-stats is called with 'serverOnly' extrapolation mode for the chart + await waitFor(() => { + expect(eventsStatsRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/events-stats/`, + expect.objectContaining({ + query: expect.objectContaining({ + extrapolationMode: 'serverOnly', + sampling: SAMPLING_MODE.NORMAL, + }), + }) + ); + }); + + // Make a change to trigger save + const nameInput = screen.getByTestId('editable-text-label'); + await userEvent.click(nameInput); + const nameInputField = await screen.findByRole('textbox', {name: /monitor name/i}); + await userEvent.type(nameInputField, ' Updated'); + + await userEvent.click(screen.getByRole('button', {name: 'Save'})); + + // Verify the update request preserves the extrapolation mode + await waitFor(() => { + expect(updateRequest).toHaveBeenCalledWith( + `/organizations/${organization.slug}/detectors/${spanDetectorWithExtrapolation.id}/`, + expect.objectContaining({ + method: 'PUT', + data: expect.objectContaining({ + dataSources: expect.arrayContaining([ + expect.objectContaining({ + extrapolationMode: ExtrapolationMode.SERVER_WEIGHTED, + }), + ]), + }), + }) + ); + }); + }); }); }); diff --git a/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx b/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx index f495c521278c5a..f67f533bc5d86d 100644 --- a/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx +++ b/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx @@ -4,7 +4,11 @@ import type {Series} from 'sentry/types/echarts'; import {useApiQuery, type UseApiQueryOptions} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import useOrganization from 'sentry/utils/useOrganization'; -import type {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; +import type { + Dataset, + EventTypes, + ExtrapolationMode, +} from 'sentry/views/alerts/rules/metric/types'; import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig'; import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; @@ -19,6 +23,7 @@ interface UseMetricDetectorSeriesProps { query: string; comparisonDelta?: number; end?: string | null; + extrapolationMode?: ExtrapolationMode; options?: Partial>; start?: string | null; statsPeriod?: string | null; @@ -48,6 +53,7 @@ export function useMetricDetectorSeries({ end, comparisonDelta, options, + extrapolationMode, }: UseMetricDetectorSeriesProps): UseMetricDetectorSeriesResult { const organization = useOrganization(); const datasetConfig = useMemo( @@ -67,6 +73,7 @@ export function useMetricDetectorSeries({ start, end, comparisonDelta, + extrapolationMode, }); const {data, isLoading, error} = useApiQuery< diff --git a/static/app/views/issueDetails/metricIssues/useMetricEventStats.tsx b/static/app/views/issueDetails/metricIssues/useMetricEventStats.tsx index d3b920d1e28d9e..07fa2e0e00457c 100644 --- a/static/app/views/issueDetails/metricIssues/useMetricEventStats.tsx +++ b/static/app/views/issueDetails/metricIssues/useMetricEventStats.tsx @@ -14,7 +14,11 @@ import { getPeriodInterval, getViableDateRange, } from 'sentry/views/alerts/rules/metric/details/utils'; -import {Dataset, type MetricRule} from 'sentry/views/alerts/rules/metric/types'; +import { + Dataset, + EAP_EXTRAPOLATION_MODE_MAP, + type MetricRule, +} from 'sentry/views/alerts/rules/metric/types'; import {extractEventTypeFilterFromRule} from 'sentry/views/alerts/rules/metric/utils/getEventTypeFilter'; import {getMetricDatasetQueryExtras} from 'sentry/views/alerts/rules/metric/utils/getMetricDatasetQueryExtras'; import {isOnDemandMetricAlert} from 'sentry/views/alerts/rules/metric/utils/onDemandMetricAlert'; @@ -74,6 +78,7 @@ export function useMetricEventStats( query: ruleQuery, environment: ruleEnvironment, eventTypes: storedEventTypes, + extrapolationMode, } = rule; const traceItemType = getTraceItemTypeForDatasetAndEventType(dataset, storedEventTypes); const interval = getPeriodInterval(timePeriod, rule); @@ -111,6 +116,9 @@ export function useMetricEventStats( yAxis: aggregate, referrer, sampling: samplingMode, + extrapolationMode: extrapolationMode + ? EAP_EXTRAPOLATION_MODE_MAP[extrapolationMode] + : undefined, ...queryExtras, }).filter(([, value]) => typeof value !== 'undefined') );