diff --git a/static/app/views/detectors/components/details/metric/chart.tsx b/static/app/views/detectors/components/details/metric/chart.tsx index 580de6a05fb9b4..ca933a1b86dca4 100644 --- a/static/app/views/detectors/components/details/metric/chart.tsx +++ b/static/app/views/detectors/components/details/metric/chart.tsx @@ -166,7 +166,7 @@ export function useMetricDetectorChart({ dataset: snubaQuery.dataset, extrapolationMode: snubaQuery.extrapolationMode, aggregate: datasetConfig.fromApiAggregate(snubaQuery.aggregate), - interval: snubaQuery.timeWindow, + timeWindow: snubaQuery.timeWindow, query: snubaQuery.query, environment: snubaQuery.environment, projectId: detector.projectId, diff --git a/static/app/views/detectors/components/details/metric/index.tsx b/static/app/views/detectors/components/details/metric/index.tsx index 415e324bc4a0ef..9502137b9e2771 100644 --- a/static/app/views/detectors/components/details/metric/index.tsx +++ b/static/app/views/detectors/components/details/metric/index.tsx @@ -1,4 +1,6 @@ import ErrorBoundary from 'sentry/components/errorBoundary'; +import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; +import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import DetailLayout from 'sentry/components/workflowEngine/layout/detail'; import type {Project} from 'sentry/types/project'; import type {MetricDetector} from 'sentry/types/workflowEngine/detectors'; @@ -8,7 +10,6 @@ import {DetectorDetailsHeader} from 'sentry/views/detectors/components/details/c import {DetectorDetailsOpenPeriodIssues} from 'sentry/views/detectors/components/details/common/openPeriodIssues'; import {MetricDetectorDetailsChart} from 'sentry/views/detectors/components/details/metric/chart'; import {MetricDetectorDetailsSidebar} from 'sentry/views/detectors/components/details/metric/sidebar'; -import {MetricTimePeriodSelect} from 'sentry/views/detectors/components/details/metric/timePeriodSelect'; import {TransactionsDatasetWarning} from 'sentry/views/detectors/components/details/metric/transactionsDatasetWarning'; import {getDetectorDataset} from 'sentry/views/detectors/datasetConfig/getDetectorDataset'; import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; @@ -24,7 +25,6 @@ export function MetricDetectorDetails({detector, project}: MetricDetectorDetails const snubaDataset = snubaQuery?.dataset ?? Dataset.ERRORS; const eventTypes = snubaQuery?.eventTypes ?? []; - const interval = snubaQuery?.timeWindow; const detectorDataset = getDetectorDataset(snubaDataset, eventTypes); const intervalSeconds = dataSource.queryObj?.snubaQuery.timeWindow; @@ -37,7 +37,9 @@ export function MetricDetectorDetails({detector, project}: MetricDetectorDetails {detectorDataset === DetectorDataset.TRANSACTIONS && ( )} - + + + {snubaQuery && ( )} diff --git a/static/app/views/detectors/components/details/metric/timePeriodSelect.spec.tsx b/static/app/views/detectors/components/details/metric/timePeriodSelect.spec.tsx deleted file mode 100644 index 26a826b1e3f6ed..00000000000000 --- a/static/app/views/detectors/components/details/metric/timePeriodSelect.spec.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {MetricTimePeriodSelect} from 'sentry/views/detectors/components/details/metric/timePeriodSelect'; -import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; - -describe('MetricTimePeriodSelect', () => { - it('syncs default statsPeriod into query when none is provided', async () => { - const {router} = render( - - ); - - await waitFor(() => { - expect(router.location.query.statsPeriod).toBe('14d'); - }); - - expect(router.location.query.start).toBeUndefined(); - expect(router.location.query.end).toBeUndefined(); - }); - - it('navigates by updating statsPeriod in the query when selecting an option', async () => { - const {router} = render( - - ); - - // Opens the select and chooses a different period - await userEvent.click( - // Default currently selected - screen.getByRole('button', {name: /last 14 days/i}) - ); - - await userEvent.click(screen.getByText(/last 7 days/i)); - - await waitFor(() => { - expect(router.location.query.statsPeriod).toBe('7d'); - }); - - // Ensure absolute range is cleared - expect(router.location.query.start).toBeUndefined(); - expect(router.location.query.end).toBeUndefined(); - }); -}); diff --git a/static/app/views/detectors/components/details/metric/timePeriodSelect.tsx b/static/app/views/detectors/components/details/metric/timePeriodSelect.tsx deleted file mode 100644 index 40f7707dc07223..00000000000000 --- a/static/app/views/detectors/components/details/metric/timePeriodSelect.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import {Fragment, useEffect, useMemo} from 'react'; -import moment from 'moment-timezone'; - -import {CompactSelect} from 'sentry/components/core/compactSelect'; -import {DateTime} from 'sentry/components/dateTime'; -import {t} from 'sentry/locale'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import { - useDetectorResolvedStatsPeriod, - useDetectorTimePeriodOptions, -} from 'sentry/views/detectors/components/details/metric/utils/useDetectorTimePeriods'; -import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; -import {MetricDetectorTimePeriod} from 'sentry/views/detectors/datasetConfig/utils/timePeriods'; - -type BaseOption = {label: React.ReactNode; value: MetricDetectorTimePeriod}; - -const CUSTOM_TIME_VALUE = '__custom_time__'; - -interface TimePeriodSelectProps { - dataset: DetectorDataset; - interval: number | undefined; -} - -export function MetricTimePeriodSelect({dataset, interval}: TimePeriodSelectProps) { - const location = useLocation(); - const navigate = useNavigate(); - - const start = location.query?.start as string | undefined; - const end = location.query?.end as string | undefined; - - const hasCustomRange = Boolean(start && end); - - const options: BaseOption[] = useDetectorTimePeriodOptions({ - dataset, - intervalSeconds: interval, - }); - - // Determine selected period from query or fallback to largest option - const selected: MetricDetectorTimePeriod = useDetectorResolvedStatsPeriod({ - dataset, - intervalSeconds: interval, - urlStatsPeriod: location.query?.statsPeriod as string | undefined, - }); - - // If there is no time selection in the URL, sync the resolved default period - // into the query params so that the rest of the page (chart, links, etc.) - // has a consistent source of truth. - useEffect(() => { - const hasStatsPeriod = Boolean(location.query?.statsPeriod); - if (!hasCustomRange && !hasStatsPeriod && selected) { - navigate( - { - pathname: location.pathname, - query: { - ...location.query, - statsPeriod: selected, - start: undefined, - end: undefined, - }, - }, - {replace: true} - ); - } - }, [hasCustomRange, selected, navigate, location.pathname, location.query]); - - const selectOptions = useMemo(() => { - if (hasCustomRange) { - const custom = { - label: ( - - {t('Custom time')}: {' — '} - - - ), - value: CUSTOM_TIME_VALUE, - textValue: t('Custom time'), - disabled: true, - }; - return [custom, ...options]; - } - - return options; - }, [hasCustomRange, start, end, options]); - - const value = hasCustomRange ? CUSTOM_TIME_VALUE : selected; - - return ( - { - // Ignore clicks on the custom option; only propagate real TimePeriod values - if (opt.value === CUSTOM_TIME_VALUE) { - return; - } - navigate({ - pathname: location.pathname, - query: { - ...location.query, - statsPeriod: opt.value, - start: undefined, - end: undefined, - }, - }); - }} - /> - ); -} diff --git a/static/app/views/detectors/components/details/metric/utils/useDetectorTimePeriods.tsx b/static/app/views/detectors/components/details/metric/utils/useDetectorTimePeriods.tsx deleted file mode 100644 index 08ada5ab651050..00000000000000 --- a/static/app/views/detectors/components/details/metric/utils/useDetectorTimePeriods.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import {useMemo} from 'react'; - -import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig'; -import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types'; -import { - getTimePeriodLabel, - MetricDetectorInterval, - MetricDetectorTimePeriod, -} from 'sentry/views/detectors/datasetConfig/utils/timePeriods'; - -/** Map a detector interval in seconds to a MetricDetectorInterval (minutes). */ -function mapIntervalSecondsToMetricInterval( - intervalSeconds: number -): MetricDetectorInterval | undefined { - const intervalMinutes = Math.floor(intervalSeconds / 60); - const validIntervals = Object.values(MetricDetectorInterval) - .filter(value => typeof value === 'number') - .sort((a, b) => a - b); - if (validIntervals.includes(intervalMinutes)) { - return intervalMinutes; - } - return undefined; -} - -/** Resolve a valid statsPeriod for a detector based on dataset and interval. */ -function resolveStatsPeriodForDetector({ - dataset, - intervalSeconds, - urlStatsPeriod, -}: { - dataset: DetectorDataset; - intervalSeconds: number | undefined; - urlStatsPeriod?: string; -}): MetricDetectorTimePeriod { - if (!intervalSeconds) { - return MetricDetectorTimePeriod.SEVEN_DAYS; - } - - const metricInterval = mapIntervalSecondsToMetricInterval(intervalSeconds); - if (!metricInterval) { - return ( - (urlStatsPeriod as MetricDetectorTimePeriod) ?? MetricDetectorTimePeriod.SEVEN_DAYS - ); - } - const datasetConfig = getDatasetConfig(dataset); - const allowed = datasetConfig.getTimePeriods(metricInterval); - if (urlStatsPeriod && allowed.includes(urlStatsPeriod as MetricDetectorTimePeriod)) { - return urlStatsPeriod as MetricDetectorTimePeriod; - } - const largest = allowed[allowed.length - 1]; - return largest ?? MetricDetectorTimePeriod.SEVEN_DAYS; -} - -type DetectorTimePeriodOption = { - label: React.ReactNode; - value: MetricDetectorTimePeriod; -}; - -export function useDetectorTimePeriodOptions(params: { - dataset: DetectorDataset | undefined; - intervalSeconds: number | undefined; -}): DetectorTimePeriodOption[] { - const {dataset, intervalSeconds} = params; - - return useMemo(() => { - if (!dataset || !intervalSeconds) { - return []; - } - const metricInterval = mapIntervalSecondsToMetricInterval(intervalSeconds); - if (!metricInterval) { - return []; - } - const datasetConfig = getDatasetConfig(dataset); - const timePeriods = datasetConfig.getTimePeriods(metricInterval); - return timePeriods.map(period => ({ - value: period, - label: getTimePeriodLabel(period), - })); - }, [dataset, intervalSeconds]); -} - -export function useDetectorResolvedStatsPeriod(params: { - dataset: DetectorDataset | undefined; - intervalSeconds: number | undefined; - urlStatsPeriod?: string; -}): MetricDetectorTimePeriod { - const {dataset, intervalSeconds, urlStatsPeriod} = params; - - return useMemo(() => { - if (!dataset) { - return MetricDetectorTimePeriod.SEVEN_DAYS; - } - return resolveStatsPeriodForDetector({ - dataset, - intervalSeconds, - urlStatsPeriod, - }); - }, [dataset, intervalSeconds, urlStatsPeriod]); -} diff --git a/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx b/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx index a06ce86fce1eb3..68fa086b959767 100644 --- a/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx +++ b/static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx @@ -163,7 +163,7 @@ export function MetricDetectorChart({ detectorDataset, dataset, aggregate, - interval, + timeWindow: interval, query, environment, projectId, diff --git a/static/app/views/detectors/datasetConfig/base.tsx b/static/app/views/detectors/datasetConfig/base.tsx index 825de780272778..304da90979bf20 100644 --- a/static/app/views/detectors/datasetConfig/base.tsx +++ b/static/app/views/detectors/datasetConfig/base.tsx @@ -43,16 +43,16 @@ interface DetectorSeriesQueryOptions { dataset: Dataset; environment: string; eventTypes: EventTypes[]; - /** - * Metric detector interval in seconds - */ - interval: number; organization: Organization; projectId: string; /** * The filter query. eg: `span.op:http` */ query: string; + /** + * Metric detector time window in seconds + */ + timeWindow: number; end?: string | null; /** * Extra query parameters to pass diff --git a/static/app/views/detectors/datasetConfig/errors.spec.tsx b/static/app/views/detectors/datasetConfig/errors.spec.tsx index 5aa256808bb44e..3cb752cf5913c1 100644 --- a/static/app/views/detectors/datasetConfig/errors.spec.tsx +++ b/static/app/views/detectors/datasetConfig/errors.spec.tsx @@ -1,5 +1,3 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - import type {SnubaQuery} from 'sentry/types/workflowEngine/detectors'; import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {DetectorErrorsConfig} from 'sentry/views/detectors/datasetConfig/errors'; @@ -35,24 +33,3 @@ describe('DetectorErrorsConfig.toSnubaQueryString', () => { expect(result).toBe('event.type:error is:unresolved'); }); }); - -describe('DetectorErrorsConfig.getSeriesQueryOptions', () => { - it('adjusts statsPeriod from 7d to 9998m when interval is 60 seconds', () => { - const options = { - aggregate: 'count()', - organization: OrganizationFixture(), - projectId: '1', - query: 'is:unresolved', - environment: '', - comparisonDelta: undefined, - dataset: Dataset.ERRORS, - eventTypes: [EventTypes.ERROR], - interval: 60, - statsPeriod: '7d', - }; - - const result = DetectorErrorsConfig.getSeriesQueryOptions(options); - - expect(result[1]!.query!.statsPeriod).toBe('9998m'); - }); -}); diff --git a/static/app/views/detectors/datasetConfig/errors.tsx b/static/app/views/detectors/datasetConfig/errors.tsx index ff8011c7f91d4c..1a3e09ae95daa8 100644 --- a/static/app/views/detectors/datasetConfig/errors.tsx +++ b/static/app/views/detectors/datasetConfig/errors.tsx @@ -6,6 +6,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {AggregationKey, FieldKey} from 'sentry/utils/fields'; import {EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {EventsSearchBar} from 'sentry/views/detectors/datasetConfig/components/eventSearchBar'; +import {getChartInterval} from 'sentry/views/detectors/datasetConfig/utils/chartInterval'; import { getDiscoverSeriesQueryOptions, transformEventsStatsComparisonSeries, @@ -81,16 +82,18 @@ export const DetectorErrorsConfig: DetectorDatasetConfig = defaultField: DEFAULT_FIELD, getAggregateOptions: () => AGGREGATE_OPTIONS, getSeriesQueryOptions: options => { - // If interval is 1 minute and statsPeriod is 7 days, apply a 9998m statsPeriod to avoid the 10k results limit. - // Applied specifically to errors dataset because it has 1m intervals, spans/logs have a minimum of 5m intervals. - if (options.interval === 60 && options.statsPeriod === '7d') { - options.statsPeriod = '9998m'; - } - return getDiscoverSeriesQueryOptions({ ...options, dataset: DetectorErrorsConfig.getDiscoverDataset(), aggregate: translateAggregateTag(options.aggregate), + interval: getChartInterval({ + timeWindow: options.timeWindow, + timeRange: { + statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, + }, + }), }); }, getIntervals: ({detectionType}) => { diff --git a/static/app/views/detectors/datasetConfig/logs.tsx b/static/app/views/detectors/datasetConfig/logs.tsx index 71bcd26f9c409a..51975eace9123f 100644 --- a/static/app/views/detectors/datasetConfig/logs.tsx +++ b/static/app/views/detectors/datasetConfig/logs.tsx @@ -4,6 +4,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {LogsConfig} from 'sentry/views/dashboards/datasetConfig/logs'; import {TraceSearchBar} from 'sentry/views/detectors/datasetConfig/components/traceSearchBar'; +import {getChartInterval} from 'sentry/views/detectors/datasetConfig/utils/chartInterval'; import { getDiscoverSeriesQueryOptions, transformEventsStatsComparisonSeries, @@ -36,6 +37,16 @@ export const DetectorLogsConfig: DetectorDatasetConfig = { return getDiscoverSeriesQueryOptions({ ...options, dataset: DetectorLogsConfig.getDiscoverDataset(), + interval: getChartInterval({ + timeWindow: options.timeWindow, + timeRange: { + statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, + }, + // See /src/sentry/search/eap/constants.py + maxBuckets: 2689, + }), }); }, getIntervals: ({detectionType}) => { diff --git a/static/app/views/detectors/datasetConfig/releases.tsx b/static/app/views/detectors/datasetConfig/releases.tsx index 5d94044ac044cd..f2be5fd169ef0c 100644 --- a/static/app/views/detectors/datasetConfig/releases.tsx +++ b/static/app/views/detectors/datasetConfig/releases.tsx @@ -9,6 +9,7 @@ import type { import {DiscoverDatasets} from 'sentry/utils/discover/types'; import type {EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {ReleaseSearchBar} from 'sentry/views/detectors/datasetConfig/components/releaseSearchBar'; +import {getChartInterval} from 'sentry/views/detectors/datasetConfig/utils/chartInterval'; import { getReleasesSeriesQueryOptions, transformMetricsResponseToSeries, @@ -110,6 +111,14 @@ export const DetectorReleasesConfig: DetectorDatasetConfig { diff --git a/static/app/views/detectors/datasetConfig/spans.tsx b/static/app/views/detectors/datasetConfig/spans.tsx index 3fdc9c8723c33e..a3856d2e4a3514 100644 --- a/static/app/views/detectors/datasetConfig/spans.tsx +++ b/static/app/views/detectors/datasetConfig/spans.tsx @@ -10,6 +10,7 @@ import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import {EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {SpansConfig} from 'sentry/views/dashboards/datasetConfig/spans'; import {TraceSearchBar} from 'sentry/views/detectors/datasetConfig/components/traceSearchBar'; +import {getChartInterval} from 'sentry/views/detectors/datasetConfig/utils/chartInterval'; import { getDiscoverSeriesQueryOptions, transformEventsStatsComparisonSeries, @@ -93,6 +94,16 @@ export const DetectorSpansConfig: DetectorDatasetConfig = { ...options, dataset: DetectorSpansConfig.getDiscoverDataset(), aggregate: translateAggregateTag(options.aggregate), + interval: getChartInterval({ + timeWindow: options.timeWindow, + timeRange: { + statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, + }, + // See /src/sentry/search/eap/constants.py + maxBuckets: 2689, + }), }); }, getIntervals: ({detectionType}) => { diff --git a/static/app/views/detectors/datasetConfig/transactions.spec.tsx b/static/app/views/detectors/datasetConfig/transactions.spec.tsx index 8cc2826a339e12..ec4e0939824d57 100644 --- a/static/app/views/detectors/datasetConfig/transactions.spec.tsx +++ b/static/app/views/detectors/datasetConfig/transactions.spec.tsx @@ -12,7 +12,7 @@ describe('DetectorTransactionsConfig', () => { const key = DetectorTransactionsConfig.getSeriesQueryOptions({ organization, aggregate: 'count()', - interval: 60, + timeWindow: 60, query: 'transaction.duration:>0', environment: 'prod', projectId: '1', @@ -31,24 +31,6 @@ describe('DetectorTransactionsConfig', () => { expect(params.project).toEqual(['1']); }); - it('expands 7d statsPeriod to 9998m for 1m intervals', () => { - const key = DetectorTransactionsConfig.getSeriesQueryOptions({ - organization, - aggregate: 'count()', - interval: 60, - query: '', - environment: '', - projectId: '1', - dataset: Dataset.TRANSACTIONS, - eventTypes: [EventTypes.TRANSACTION], - statsPeriod: '7d', - comparisonDelta: undefined, - }); - - const params = key[1]!.query!; - expect(params.statsPeriod).toBe('9998m'); - }); - it('on-demand success (apdex) returns METRICS_ENHANCED and prefixed query', () => { const orgWithFeature = OrganizationFixture({ features: ['on-demand-metrics-extraction', 'on-demand-metrics-ui'], @@ -57,7 +39,7 @@ describe('DetectorTransactionsConfig', () => { const key = DetectorTransactionsConfig.getSeriesQueryOptions({ organization: orgWithFeature, aggregate: 'apdex()', - interval: 60, + timeWindow: 60, query: 'transaction.duration:>0', environment: '', projectId: '1', diff --git a/static/app/views/detectors/datasetConfig/transactions.tsx b/static/app/views/detectors/datasetConfig/transactions.tsx index 35490c553aed37..3d2ceb6e261e22 100644 --- a/static/app/views/detectors/datasetConfig/transactions.tsx +++ b/static/app/views/detectors/datasetConfig/transactions.tsx @@ -9,6 +9,7 @@ import {hasOnDemandMetricAlertFeature} from 'sentry/utils/onDemandMetrics/featur import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; import {TransactionsConfig} from 'sentry/views/dashboards/datasetConfig/transactions'; import {TraceSearchBar} from 'sentry/views/detectors/datasetConfig/components/traceSearchBar'; +import {getChartInterval} from 'sentry/views/detectors/datasetConfig/utils/chartInterval'; import { getDiscoverSeriesQueryOptions, transformEventsStatsComparisonSeries, @@ -18,7 +19,6 @@ import { BASE_DYNAMIC_INTERVALS, BASE_INTERVALS, getStandardTimePeriodsForInterval, - MetricDetectorTimePeriod, } from 'sentry/views/detectors/datasetConfig/utils/timePeriods'; import { translateAggregateTag, @@ -77,14 +77,6 @@ export const DetectorTransactionsConfig: DetectorDatasetConfig { - // Force statsPeriod to be 9998m to avoid the 10k results limit. - // This is specific to the transactions dataset, since it has 1m intervals and does not support 10k+ results. - const isOneMinuteInterval = options.interval === 60; - const timePeriod = - options.statsPeriod === MetricDetectorTimePeriod.SEVEN_DAYS && isOneMinuteInterval - ? '9998m' - : options.statsPeriod; - const hasMetricDataset = hasOnDemandMetricAlertFeature(options.organization) || options.organization.features.includes('mep-rollout-flag') || @@ -103,10 +95,17 @@ export const DetectorTransactionsConfig: DetectorDatasetConfig { diff --git a/static/app/views/detectors/datasetConfig/utils/chartInterval.spec.tsx b/static/app/views/detectors/datasetConfig/utils/chartInterval.spec.tsx new file mode 100644 index 00000000000000..466f972b4b3294 --- /dev/null +++ b/static/app/views/detectors/datasetConfig/utils/chartInterval.spec.tsx @@ -0,0 +1,81 @@ +import {getChartInterval} from './chartInterval'; + +describe('getChartInterval', () => { + const MAX_BUCKETS = 10_000; + + it('returns detector time window for small time ranges', () => { + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: MAX_BUCKETS, + timeRange: {statsPeriod: '6h'}, + }) + ).toBe(60); + }); + + it('snaps to 5 minute preset for 7 day time range', () => { + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: MAX_BUCKETS, + timeRange: {statsPeriod: '7d'}, + }) + ).toBe(300); + }); + + it('snaps to 15 minute preset for 90 day time range', () => { + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: MAX_BUCKETS, + timeRange: {statsPeriod: '90d'}, + }) + ).toBe(900); + }); + + it('respects detector time window when larger than calculated interval', () => { + expect( + getChartInterval({ + timeWindow: 3600, + maxBuckets: MAX_BUCKETS, + timeRange: {statsPeriod: '6h'}, + }) + ).toBe(3600); + }); + + it('returns detector time window when time range is invalid', () => { + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: MAX_BUCKETS, + timeRange: {}, + }) + ).toBe(60); + }); + + it('works with absolute start/end dates', () => { + // 7 days via absolute dates + const start = '2024-01-01T00:00:00Z'; + const end = '2024-01-08T00:00:00Z'; + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: MAX_BUCKETS, + timeRange: {start, end}, + }) + ).toBe(300); // 5 minutes + }); + + it('respects custom maxBuckets limit', () => { + // 7 days = 604,800 seconds + // With maxBuckets = 1000: rawMinInterval = ceil(604800 / 1000) = 605 seconds + // Snaps to 900 seconds (15 minutes) + expect( + getChartInterval({ + timeWindow: 60, + maxBuckets: 1000, + timeRange: {statsPeriod: '7d'}, + }) + ).toBe(900); + }); +}); diff --git a/static/app/views/detectors/datasetConfig/utils/chartInterval.tsx b/static/app/views/detectors/datasetConfig/utils/chartInterval.tsx new file mode 100644 index 00000000000000..3cf2777fb03ed8 --- /dev/null +++ b/static/app/views/detectors/datasetConfig/utils/chartInterval.tsx @@ -0,0 +1,97 @@ +import {parseStatsPeriod} from 'sentry/components/organizations/pageFilters/parse'; + +// See MAX_ROLLUP_POINTS in sentry/constants.py +const DEFAULT_MAX_BUCKETS = 10000; + +const INTERVAL_PRESETS_SECONDS = [ + 60, // 1 minute + 300, // 5 minutes + 900, // 15 minutes + 1800, // 30 minutes + 3600, // 1 hour + 7200, // 2 hours + 14400, // 4 hours + 86400, // 1 day +] as const; + +function getTimeRangeInSeconds({ + statsPeriod, + start, + end, +}: { + end?: string | null; + start?: string | null; + statsPeriod?: string | null; +}): number { + if (statsPeriod) { + const parsed = parseStatsPeriod(statsPeriod); + if (parsed?.period) { + const {period, periodLength} = parsed; + const value = parseInt(period, 10); + const multipliers: Record = { + s: 1, + m: 60, + h: 60 * 60, + d: 24 * 60 * 60, + w: 7 * 24 * 60 * 60, + }; + return value * (multipliers[periodLength] ?? 1); + } + } + if (start && end) { + return (new Date(end).getTime() - new Date(start).getTime()) / 1000; + } + return 0; +} + +/** + * Snaps a value to the smallest preset interval that is >= the given value. + * Falls back to the largest preset if the value exceeds all presets. + */ +function snapToPresetInterval(minIntervalSeconds: number): number { + for (const preset of INTERVAL_PRESETS_SECONDS) { + if (preset >= minIntervalSeconds) { + return preset; + } + } + // If minInterval exceeds all presets, use the largest one (1 day) + return INTERVAL_PRESETS_SECONDS.at(-1) ?? 86400; +} + +interface GetChartIntervalOptions { + timeRange: { + end?: string | null; + start?: string | null; + statsPeriod?: string | null; + }; + /** + * The detector's configured time window in seconds. + */ + timeWindow: number; + maxBuckets?: number; +} + +/** + * Calculates the appropriate chart interval in seconds, ensuring we don't exceed + * the maximum number of data points for the given dataset. + * + * The returned interval is: + * 1. Calculated based on the time range and max data points + * 2. Snapped to a preset value (1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d) + * 3. At least as large as the detector's configured time window + */ +export function getChartInterval({ + timeWindow, + timeRange, + maxBuckets = DEFAULT_MAX_BUCKETS, +}: GetChartIntervalOptions): number { + const timeRangeSeconds = getTimeRangeInSeconds(timeRange); + if (timeRangeSeconds <= 0) { + return timeWindow; + } + + const rawMinInterval = Math.ceil(timeRangeSeconds / maxBuckets); + const snappedInterval = snapToPresetInterval(rawMinInterval); + + return Math.max(timeWindow, snappedInterval); +} diff --git a/static/app/views/detectors/datasetConfig/utils/timePeriods.tsx b/static/app/views/detectors/datasetConfig/utils/timePeriods.tsx index c0731307b9b2f9..e0b61885b154de 100644 --- a/static/app/views/detectors/datasetConfig/utils/timePeriods.tsx +++ b/static/app/views/detectors/datasetConfig/utils/timePeriods.tsx @@ -1,6 +1,3 @@ -import {t} from 'sentry/locale'; -import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; - export enum MetricDetectorTimePeriod { SIX_HOURS = '6h', ONE_DAY = '1d', @@ -149,21 +146,3 @@ export function getEapTimePeriodsForInterval( ): MetricDetectorTimePeriod[] { return EAP_TIME_PERIODS_MAP[interval] ?? []; } - -/** - * Return a human-readable label for a time period, mirroring TIME_PERIOD_MAP. - * Uses hours for 1d (Last 24 hours) and days for multi-day periods. - */ -export function getTimePeriodLabel(period: MetricDetectorTimePeriod): string { - const hours = parsePeriodToHours(period as unknown as string); - if (hours <= 0) { - return period as unknown as string; - } - if (hours <= 24) { - return t('Last %s hours', hours); - } - if (hours % 24 === 0) { - return t('Last %s days', hours / 24); - } - return t('Last %s hours', hours); -} diff --git a/static/app/views/detectors/hooks/useMetricDetectorAnomalyPeriods.tsx b/static/app/views/detectors/hooks/useMetricDetectorAnomalyPeriods.tsx index 244db098427c70..c3501eaa9d09ed 100644 --- a/static/app/views/detectors/hooks/useMetricDetectorAnomalyPeriods.tsx +++ b/static/app/views/detectors/hooks/useMetricDetectorAnomalyPeriods.tsx @@ -155,7 +155,7 @@ export function useMetricDetectorAnomalyPeriods({ detectorDataset, dataset, aggregate, - interval, + timeWindow: interval, query, eventTypes, environment, diff --git a/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx b/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx index f67f533bc5d86d..b4d0302cc88f30 100644 --- a/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx +++ b/static/app/views/detectors/hooks/useMetricDetectorSeries.tsx @@ -18,9 +18,9 @@ interface UseMetricDetectorSeriesProps { detectorDataset: DetectorDataset; environment: string | undefined; eventTypes: EventTypes[]; - interval: number; projectId: string; query: string; + timeWindow: number; comparisonDelta?: number; end?: string | null; extrapolationMode?: ExtrapolationMode; @@ -43,7 +43,7 @@ export function useMetricDetectorSeries({ detectorDataset, dataset, aggregate, - interval, + timeWindow, query, eventTypes, environment, @@ -63,7 +63,7 @@ export function useMetricDetectorSeries({ const seriesQueryOptions = datasetConfig.getSeriesQueryOptions({ organization, aggregate, - interval, + timeWindow, query, environment: environment || '', projectId,