Skip to content

Commit 84f6ff0

Browse files
scttcperandrewshie-sentry
authored andcommitted
feat(aci): Add basic support for static thresholds (#95085)
- sets up the component to be reused for detector details - adds a hook for thresholds - adds a hook for the detector series <img width="886" height="270" alt="image" src="https://github.com/user-attachments/assets/5831f52b-8fc1-4df8-a834-38b291a37c08" />
1 parent d5a549f commit 84f6ff0

File tree

8 files changed

+404
-87
lines changed

8 files changed

+404
-87
lines changed

static/app/views/detectors/components/forms/editDetectorLayout.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,14 @@ export function EditDetectorLayout({
103103
<StyledLayoutHeader>
104104
<Layout.HeaderContent>
105105
<DetectorBreadcrumbs detector={detector} />
106-
<Flex direction="column" gap={space(2)}>
107-
<DetectorBaseFields />
108-
{previewChart}
109-
</Flex>
110106
</Layout.HeaderContent>
111-
<EditDetectorActions detectorId={detector.id} />
107+
<Flex>
108+
<EditDetectorActions detectorId={detector.id} />
109+
</Flex>
110+
<FullWidthContent>
111+
<DetectorBaseFields />
112+
{previewChart}
113+
</FullWidthContent>
112114
</StyledLayoutHeader>
113115
<Layout.Body>
114116
<Layout.Main fullWidth>{children}</Layout.Main>
@@ -134,3 +136,10 @@ export function EditDetectorLayout({
134136
const StyledLayoutHeader = styled(Layout.Header)`
135137
background-color: ${p => p.theme.background};
136138
`;
139+
140+
const FullWidthContent = styled('div')`
141+
grid-column: 1 / -1;
142+
display: flex;
143+
flex-direction: column;
144+
gap: ${space(2)};
145+
`;
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {useMemo} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {AreaChart} from 'sentry/components/charts/areaChart';
5+
import ErrorPanel from 'sentry/components/charts/errorPanel';
6+
import {Flex} from 'sentry/components/core/layout';
7+
import Placeholder from 'sentry/components/placeholder';
8+
import {IconWarning} from 'sentry/icons';
9+
import {t} from 'sentry/locale';
10+
import {space} from 'sentry/styles/space';
11+
import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions';
12+
import type {MetricDetectorConfig} from 'sentry/types/workflowEngine/detectors';
13+
import type {DetectorDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
14+
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
15+
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
16+
17+
const CHART_HEIGHT = 150;
18+
19+
interface MetricDetectorChartProps {
20+
/**
21+
* The aggregate function to use (e.g., "avg(span.duration)")
22+
*/
23+
aggregate: string;
24+
/**
25+
* The condition group containing threshold conditions
26+
*/
27+
conditions: Array<Omit<DataCondition, 'id'>>;
28+
/**
29+
* The dataset to use for the chart
30+
*/
31+
dataset: DetectorDataset;
32+
detectionType: MetricDetectorConfig['detectionType'];
33+
/**
34+
* The environment filter
35+
*/
36+
environment: string | undefined;
37+
/**
38+
* The time interval in seconds
39+
*/
40+
interval: number;
41+
/**
42+
* The project ID
43+
*/
44+
projectId: string;
45+
/**
46+
* The query filter string
47+
*/
48+
query: string;
49+
}
50+
51+
export function MetricDetectorChart({
52+
dataset,
53+
aggregate,
54+
interval,
55+
query,
56+
environment,
57+
projectId,
58+
conditions,
59+
detectionType,
60+
}: MetricDetectorChartProps) {
61+
const {series, isPending, isError} = useMetricDetectorSeries({
62+
dataset,
63+
aggregate,
64+
interval,
65+
query,
66+
environment,
67+
projectId,
68+
});
69+
70+
const {series: thresholdSeries, maxValue: thresholdMaxValue} =
71+
useMetricDetectorThresholdSeries({
72+
conditions,
73+
detectionType,
74+
});
75+
76+
// Calculate y-axis bounds to ensure all thresholds are visible
77+
const yAxisBounds = useMemo((): {max: number | undefined; min: number | undefined} => {
78+
if (thresholdMaxValue === undefined) {
79+
return {min: undefined, max: undefined};
80+
}
81+
// Get series data bounds
82+
const seriesData = series[0]?.data || [];
83+
const seriesValues = seriesData.map(point => point.value).filter(val => !isNaN(val));
84+
85+
// Calculate bounds including thresholds
86+
const allValues = [...seriesValues, thresholdMaxValue];
87+
const min = allValues.length > 0 ? Math.min(...allValues) : 0;
88+
const max = allValues.length > 0 ? Math.max(...allValues) : 0;
89+
90+
// Add some padding to the bounds
91+
const padding = (max - min) * 0.1;
92+
const paddedMin = Math.max(0, min - padding);
93+
const paddedMax = max + padding;
94+
95+
return {
96+
min: Math.round(paddedMin),
97+
max: Math.round(paddedMax),
98+
};
99+
}, [series, thresholdMaxValue]);
100+
101+
const mergedSeries = useMemo(() => {
102+
return [...series, ...thresholdSeries];
103+
}, [series, thresholdSeries]);
104+
105+
if (isPending) {
106+
return (
107+
<ChartContainer>
108+
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
109+
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
110+
</Flex>
111+
</ChartContainer>
112+
);
113+
}
114+
115+
if (isError) {
116+
return (
117+
<ChartContainer>
118+
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
119+
<ErrorPanel>
120+
<IconWarning color="gray300" size="lg" />
121+
<div>{t('Error loading chart data')}</div>
122+
</ErrorPanel>
123+
</Flex>
124+
</ChartContainer>
125+
);
126+
}
127+
128+
return (
129+
<ChartContainer>
130+
<AreaChart
131+
isGroupedByDate
132+
showTimeInTooltip
133+
height={CHART_HEIGHT}
134+
stacked={false}
135+
series={mergedSeries}
136+
yAxis={{
137+
min: yAxisBounds.min,
138+
max: yAxisBounds.max,
139+
axisLabel: {
140+
// Hide the maximum y-axis label to avoid showing arbitrary threshold values
141+
showMaxLabel: false,
142+
},
143+
}}
144+
grid={{
145+
left: space(0.25),
146+
right: space(0.5),
147+
top: space(1),
148+
bottom: space(1),
149+
}}
150+
/>
151+
</ChartContainer>
152+
);
153+
}
154+
155+
const ChartContainer = styled('div')`
156+
max-width: 1440px;
157+
border-top: 1px solid ${p => p.theme.border};
158+
`;

static/app/views/detectors/components/forms/metric/metricFormData.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,12 @@ interface NewDataSource {
182182
/**
183183
* Creates escalation conditions based on priority level and available thresholds
184184
*/
185-
function createConditions(data: MetricDetectorFormData): NewConditionGroup['conditions'] {
185+
export function createConditions(
186+
data: Pick<
187+
MetricDetectorFormData,
188+
'conditionType' | 'conditionValue' | 'initialPriorityLevel' | 'highThreshold'
189+
>
190+
): NewConditionGroup['conditions'] {
186191
if (!defined(data.conditionType) || !defined(data.conditionValue)) {
187192
return [];
188193
}
@@ -236,7 +241,10 @@ const getDetectorDataset = (
236241
if (eventTypes.includes(EventTypes.TRACE_ITEM_LOG)) {
237242
return DetectorDataset.LOGS;
238243
}
239-
throw new Error('Unsupported event types');
244+
if (eventTypes.includes(EventTypes.TRANSACTION)) {
245+
return DetectorDataset.TRANSACTIONS;
246+
}
247+
throw new Error(`Unsupported event types`);
240248
case Dataset.METRICS:
241249
case Dataset.SESSIONS:
242250
return DetectorDataset.RELEASES; // Maps metrics dataset to releases for crash rate
@@ -254,11 +262,11 @@ const getBackendDataset = (dataset: DetectorDataset): string => {
254262
case DetectorDataset.ERRORS:
255263
return Dataset.ERRORS;
256264
case DetectorDataset.TRANSACTIONS:
257-
return Dataset.GENERIC_METRICS;
265+
return Dataset.EVENTS_ANALYTICS_PLATFORM;
258266
case DetectorDataset.SPANS:
259267
return Dataset.EVENTS_ANALYTICS_PLATFORM;
260268
case DetectorDataset.RELEASES:
261-
return Dataset.METRICS; // Maps to metrics dataset for crash rate queries
269+
return Dataset.METRICS;
262270
case DetectorDataset.LOGS:
263271
return Dataset.EVENTS_ANALYTICS_PLATFORM;
264272
default:
Lines changed: 43 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,63 @@
11
import {useMemo} from 'react';
2-
import styled from '@emotion/styled';
32

4-
import {AreaChart} from 'sentry/components/charts/areaChart';
5-
import ErrorPanel from 'sentry/components/charts/errorPanel';
6-
import {Flex} from 'sentry/components/core/layout';
7-
import Placeholder from 'sentry/components/placeholder';
8-
import {IconWarning} from 'sentry/icons';
9-
import {t} from 'sentry/locale';
10-
import {useApiQuery} from 'sentry/utils/queryClient';
11-
import useOrganization from 'sentry/utils/useOrganization';
3+
import {MetricDetectorChart} from 'sentry/views/detectors/components/forms/metric/metricDetectorChart';
124
import {
5+
createConditions,
136
METRIC_DETECTOR_FORM_FIELDS,
147
useMetricDetectorFormField,
158
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
16-
import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig';
17-
import {DETECTOR_DATASET_TO_DISCOVER_DATASET_MAP} from 'sentry/views/detectors/datasetConfig/utils/discoverDatasetMap';
18-
19-
const CHART_HEIGHT = 175;
209

2110
export function MetricDetectorPreviewChart() {
22-
const organization = useOrganization();
11+
// Get all the form fields needed for the chart
2312
const dataset = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.dataset);
24-
const aggregate = useMetricDetectorFormField(
13+
const aggregateFunction = useMetricDetectorFormField(
2514
METRIC_DETECTOR_FORM_FIELDS.aggregateFunction
2615
);
2716
const interval = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.interval);
2817
const query = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.query);
2918
const environment = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.environment);
3019
const projectId = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.projectId);
3120

32-
const datasetConfig = useMemo(() => getDatasetConfig(dataset), [dataset]);
33-
const seriesQueryOptions = datasetConfig.getSeriesQueryOptions({
34-
organization,
35-
aggregate,
36-
interval,
37-
query,
38-
environment,
39-
projectId: Number(projectId),
40-
dataset: DETECTOR_DATASET_TO_DISCOVER_DATASET_MAP[dataset],
41-
});
42-
43-
const {data, isPending, isError} = useApiQuery<
44-
Parameters<typeof datasetConfig.transformSeriesQueryData>[0]
45-
>(seriesQueryOptions, {
46-
// 5 minutes
47-
staleTime: 5 * 60 * 1000,
48-
});
49-
50-
const series = useMemo(() => {
51-
// TypeScript can't infer that each dataset config expects its own specific response type
52-
return datasetConfig.transformSeriesQueryData(data as any, aggregate);
53-
}, [datasetConfig, data, aggregate]);
54-
55-
if (isPending) {
56-
return (
57-
<PreviewChartContainer>
58-
<Placeholder height={`${CHART_HEIGHT}px`} />
59-
</PreviewChartContainer>
60-
);
61-
}
62-
63-
if (isError) {
64-
return (
65-
<PreviewChartContainer>
66-
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
67-
<ErrorPanel>
68-
<IconWarning color="gray300" size="lg" />
69-
<div>{t('Error loading chart data')}</div>
70-
</ErrorPanel>
71-
</Flex>
72-
</PreviewChartContainer>
73-
);
74-
}
21+
// Threshold-related form fields
22+
const conditionValue = useMetricDetectorFormField(
23+
METRIC_DETECTOR_FORM_FIELDS.conditionValue
24+
);
25+
const conditionType = useMetricDetectorFormField(
26+
METRIC_DETECTOR_FORM_FIELDS.conditionType
27+
);
28+
const highThreshold = useMetricDetectorFormField(
29+
METRIC_DETECTOR_FORM_FIELDS.highThreshold
30+
);
31+
const initialPriorityLevel = useMetricDetectorFormField(
32+
METRIC_DETECTOR_FORM_FIELDS.initialPriorityLevel
33+
);
34+
const kind = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.kind);
35+
36+
// Create condition group from form data using the helper function
37+
const conditions = useMemo(() => {
38+
// Wait for a condition value to be defined
39+
if (kind === 'static' && !conditionValue) {
40+
return [];
41+
}
42+
43+
return createConditions({
44+
conditionType,
45+
conditionValue,
46+
initialPriorityLevel,
47+
highThreshold,
48+
});
49+
}, [conditionType, conditionValue, initialPriorityLevel, highThreshold, kind]);
7550

7651
return (
77-
<PreviewChartContainer>
78-
<AreaChart
79-
series={series}
80-
height={CHART_HEIGHT}
81-
stacked={false}
82-
isGroupedByDate
83-
showTimeInTooltip
84-
/>
85-
</PreviewChartContainer>
52+
<MetricDetectorChart
53+
dataset={dataset}
54+
aggregate={aggregateFunction}
55+
interval={interval}
56+
query={query}
57+
environment={environment}
58+
projectId={projectId}
59+
conditions={conditions}
60+
detectionType={kind}
61+
/>
8662
);
8763
}
88-
89-
const PreviewChartContainer = styled('div')`
90-
max-width: 1440px;
91-
border-top: 1px solid ${p => p.theme.border};
92-
`;

static/app/views/detectors/components/forms/newDetectorLayout.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export function NewDetectorLayout({
109109
title={t('New %s Monitor', DETECTOR_TYPE_LABELS[detectorType])}
110110
/>
111111
<Layout.Page>
112-
<StyledLayoutHeader>
112+
<StyledLayoutHeader noActionWrap>
113113
<Layout.HeaderContent>
114114
<Breadcrumbs
115115
crumbs={[
@@ -119,11 +119,13 @@ export function NewDetectorLayout({
119119
},
120120
]}
121121
/>
122-
<Flex direction="column" gap={space(2)}>
123-
<DetectorBaseFields />
124-
{previewChart}
125-
</Flex>
126122
</Layout.HeaderContent>
123+
{/* Header actions placeholder - currently unused */}
124+
<div />
125+
<Flex direction="column" gap={space(2)}>
126+
<DetectorBaseFields />
127+
{previewChart}
128+
</Flex>
127129
</StyledLayoutHeader>
128130
<Layout.Body>
129131
<Layout.Main fullWidth>{children}</Layout.Main>

0 commit comments

Comments
 (0)