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,