Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 23 additions & 35 deletions static/app/views/detectors/components/details/metric/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
buildDetectorZoomQuery,
computeZoomRangeMs,
} from 'sentry/views/detectors/components/details/common/buildDetectorZoomQuery';
import {useDetectorChartAxisBounds} from 'sentry/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds';
import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig';
import {getDetectorDataset} from 'sentry/views/detectors/datasetConfig/getDetectorDataset';
import {
Expand Down Expand Up @@ -161,11 +162,12 @@ export function useMetricDetectorChart({
const snubaQuery = detector.dataSources[0].queryObj.snubaQuery;
const dataset = getDetectorDataset(snubaQuery.dataset, snubaQuery.eventTypes);
const datasetConfig = getDatasetConfig(dataset);
const aggregate = datasetConfig.fromApiAggregate(snubaQuery.aggregate);
const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({
detectorDataset: dataset,
dataset: snubaQuery.dataset,
extrapolationMode: snubaQuery.extrapolationMode,
aggregate: datasetConfig.fromApiAggregate(snubaQuery.aggregate),
aggregate,
interval: snubaQuery.timeWindow,
query: snubaQuery.query,
environment: snubaQuery.environment,
Expand All @@ -181,6 +183,7 @@ export function useMetricDetectorChart({
useMetricDetectorThresholdSeries({
conditions: detector.conditionGroup?.conditions,
detectionType,
aggregate,
comparisonSeries,
});

Expand Down Expand Up @@ -221,30 +224,7 @@ export function useMetricDetectorChart({
usePageDate: true,
});

// Calculate y-axis bounds to ensure all thresholds are visible
const maxValue = useMemo(() => {
// Get max from series data
let seriesMax = 0;
if (series.length > 0) {
const allSeriesValues = series.flatMap(s =>
s.data
.map(point => point.value)
.filter(val => typeof val === 'number' && !isNaN(val))
);
seriesMax = allSeriesValues.length > 0 ? Math.max(...allSeriesValues) : 0;
}

// Combine with threshold max and round to nearest whole number
const combinedMax = thresholdMaxValue
? Math.max(seriesMax, thresholdMaxValue)
: seriesMax;

const roundedMax = Math.round(combinedMax);

// Add padding to the bounds
const padding = roundedMax * 0.1;
return roundedMax + padding;
}, [series, thresholdMaxValue]);
const {maxValue, minValue} = useDetectorChartAxisBounds({series, thresholdMaxValue});

const additionalSeries = useMemo(() => {
const baseSeries = [...thresholdAdditionalSeries];
Expand All @@ -256,18 +236,25 @@ export function useMetricDetectorChart({
}, [thresholdAdditionalSeries, openPeriodMarkerResult.incidentMarkerSeries]);

const yAxes = useMemo(() => {
const {formatYAxisLabel} = getDetectorChartFormatters({
const {formatYAxisLabel, outputType} = getDetectorChartFormatters({
detectionType,
aggregate: snubaQuery.aggregate,
aggregate,
});

const isPercentage = outputType === 'percentage';
// For percentage aggregates, use fixed max of 1 (100%) and calculated min
const yAxisMax = isPercentage ? 1 : maxValue > 0 ? maxValue : undefined;
// Start charts at 0 for non-percentage aggregates
const yAxisMin = isPercentage ? minValue : 0;

const mainYAxis: YAXisComponentOption = {
max: maxValue > 0 ? maxValue : undefined,
min: 0,
max: yAxisMax,
min: yAxisMin,
axisLabel: {
// Hide the maximum y-axis label to avoid showing arbitrary threshold values
showMaxLabel: false,
formatter: (value: number) => formatYAxisLabel(value),
// Show max label for percentage (100%) but hide for other types to avoid arbitrary values
showMaxLabel: isPercentage,
// Format the axis labels with units
formatter: formatYAxisLabel,
},
// Disable the y-axis grid lines
splitLine: {show: false},
Expand All @@ -282,8 +269,9 @@ export function useMetricDetectorChart({
return axes;
}, [
detectionType,
snubaQuery.aggregate,
aggregate,
maxValue,
minValue,
openPeriodMarkerResult.incidentMarkerYAxis,
]);

Expand Down Expand Up @@ -314,7 +302,7 @@ export function useMetricDetectorChart({
tooltip: {
valueFormatter: getDetectorChartFormatters({
detectionType,
aggregate: snubaQuery.aggregate,
aggregate,
}).formatTooltipValue,
},
...chartZoomProps,
Expand All @@ -325,6 +313,7 @@ export function useMetricDetectorChart({
};
}, [
additionalSeries,
aggregate,
chartZoomProps,
detectionType,
error,
Expand All @@ -333,7 +322,6 @@ export function useMetricDetectorChart({
isLoading,
openPeriodMarkerResult,
series,
snubaQuery.aggregate,
yAxes,
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {useMemo} from 'react';

import type {Series} from 'sentry/types/echarts';

interface UseChartAxisBoundsProps {
series: Series[];
thresholdMaxValue: number | undefined;
}

interface ChartAxisBounds {
maxValue: number;
minValue: number;
}

/**
* Calculates y-axis bounds for detector charts based on series data and threshold values.
* Adds padding to ensure all data points and thresholds are visible.
*/
export function useDetectorChartAxisBounds({
series,
thresholdMaxValue,
}: UseChartAxisBoundsProps): ChartAxisBounds {
return useMemo(() => {
if (series.length === 0) {
return {maxValue: 0, minValue: 0};
}

const allSeriesValues = series.flatMap(s =>
s.data
.map(point => point.value)
.filter(val => typeof val === 'number' && !isNaN(val))
);

if (allSeriesValues.length === 0) {
return {maxValue: 0, minValue: 0};
}

const seriesMax = Math.max(...allSeriesValues);
const seriesMin = Math.min(...allSeriesValues);

// Combine with threshold max and round to nearest whole number
const combinedMax = thresholdMaxValue
? Math.max(seriesMax, thresholdMaxValue)
: seriesMax;

const roundedMax = Math.round(combinedMax);

// Add padding to the bounds
const maxPadding = roundedMax * 0.1;
const minPadding = seriesMin * 0.1;

return {
maxValue: roundedMax + maxPadding,
minValue: Math.max(0, seriesMin - minPadding),
};
}, [series, thresholdMaxValue]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
EventTypes,
ExtrapolationMode,
} from 'sentry/views/alerts/rules/metric/types';
import {useDetectorChartAxisBounds} from 'sentry/views/detectors/components/details/metric/utils/useDetectorChartAxisBounds';
import {getBackendDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
import {useIncidentMarkers} from 'sentry/views/detectors/hooks/useIncidentMarkers';
Expand Down Expand Up @@ -177,6 +178,7 @@ export function MetricDetectorChart({
useMetricDetectorThresholdSeries({
conditions,
detectionType,
aggregate,
comparisonSeries,
});

Expand Down Expand Up @@ -216,30 +218,7 @@ export function MetricDetectorChart({
markLineTooltip: anomalyMarklineTooltip,
});

// Calculate y-axis bounds to ensure all thresholds are visible
const maxValue = useMemo(() => {
// Get max from series data
let seriesMax = 0;
if (series.length > 0) {
const allSeriesValues = series.flatMap(s =>
s.data
.map(point => point.value)
.filter(val => typeof val === 'number' && !isNaN(val))
);
seriesMax = allSeriesValues.length > 0 ? Math.max(...allSeriesValues) : 0;
}

// Combine with threshold max and round to nearest whole number
const combinedMax = thresholdMaxValue
? Math.max(seriesMax, thresholdMaxValue)
: seriesMax;

const roundedMax = Math.round(combinedMax);

// Add padding to the bounds
const padding = roundedMax * 0.1;
return roundedMax + padding;
}, [series, thresholdMaxValue]);
const {maxValue, minValue} = useDetectorChartAxisBounds({series, thresholdMaxValue});

const additionalSeries = useMemo(() => {
const baseSeries = [...thresholdAdditionalSeries];
Expand All @@ -257,17 +236,22 @@ export function MetricDetectorChart({
]);

const yAxes = useMemo(() => {
const {formatYAxisLabel} = getDetectorChartFormatters({
const {formatYAxisLabel, outputType} = getDetectorChartFormatters({
detectionType,
aggregate,
});

const isPercentage = outputType === 'percentage';
// For percentage aggregates, use fixed max of 1 (100%) and calculated min
const yAxisMax = isPercentage ? 1 : maxValue > 0 ? maxValue : undefined;
const yAxisMin = isPercentage ? minValue : 0;

const mainYAxis: YAXisComponentOption = {
max: maxValue > 0 ? maxValue : undefined,
min: 0,
max: yAxisMax,
min: yAxisMin,
axisLabel: {
// Hide the maximum y-axis label to avoid showing arbitrary threshold values
showMaxLabel: false,
// Show max label for percentage (100%) but hide for other types to avoid arbitrary values
showMaxLabel: isPercentage,
// Format the axis labels with units
formatter: formatYAxisLabel,
},
Expand All @@ -285,6 +269,7 @@ export function MetricDetectorChart({
return axes;
}, [
maxValue,
minValue,
isAnomalyDetection,
anomalyMarkerResult.incidentMarkerYAxis,
detectionType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
MetricCondition,
MetricDetectorConfig,
} from 'sentry/types/workflowEngine/detectors';
import {aggregateOutputType} from 'sentry/utils/discover/fields';

function createThresholdMarkLine(lineColor: string, threshold: number) {
return MarkLine({
Expand Down Expand Up @@ -117,6 +118,10 @@ function extractThresholdsFromConditions(
interface UseMetricDetectorThresholdSeriesProps {
conditions: Array<Omit<MetricCondition, 'id'>> | undefined;
detectionType: MetricDetectorConfig['detectionType'];
/**
* The aggregate function to determine if thresholds should be scaled for percentage display
*/
aggregate?: string;
comparisonSeries?: Series[];
}

Expand All @@ -134,11 +139,16 @@ interface UseMetricDetectorThresholdSeriesResult {
export function useMetricDetectorThresholdSeries({
conditions,
detectionType,
aggregate,
comparisonSeries = [],
}: UseMetricDetectorThresholdSeriesProps): UseMetricDetectorThresholdSeriesResult {
const theme = useTheme();

return useMemo((): UseMetricDetectorThresholdSeriesResult => {
// For percentage aggregates (e.g., crash-free rate), thresholds are input as whole numbers
// (e.g., 95 for 95%) but need to be displayed as decimals (0.95) on the chart
const isPercentageAggregate =
aggregate && aggregateOutputType(aggregate) === 'percentage';
if (!conditions) {
return {maxValue: undefined, additionalSeries: []};
}
Expand Down Expand Up @@ -216,11 +226,15 @@ export function useMetricDetectorThresholdSeries({
? theme.red300
: theme.yellow300;
const areaColor = lineColor;
// Scale threshold for percentage aggregates (e.g., 95 -> 0.95)
const displayThreshold = isPercentageAggregate
? threshold.value / 100
: threshold.value;

return {
type: 'line',
markLine: createThresholdMarkLine(lineColor, threshold.value),
markArea: createThresholdMarkArea(areaColor, threshold.value, isAbove),
markLine: createThresholdMarkLine(lineColor, displayThreshold),
markArea: createThresholdMarkArea(areaColor, displayThreshold, isAbove),
data: [],
};
});
Expand All @@ -231,12 +245,16 @@ export function useMetricDetectorThresholdSeries({
resolution && !thresholds.some(threshold => threshold.value === resolution.value)
);
if (resolution && isResolutionManual) {
// Scale resolution threshold for percentage aggregates
const displayResolution = isPercentageAggregate
? resolution.value / 100
: resolution.value;
const resolutionSeries: LineSeriesOption = {
type: 'line',
markLine: createThresholdMarkLine(theme.green300, resolution.value),
markLine: createThresholdMarkLine(theme.green300, displayResolution),
markArea: createThresholdMarkArea(
theme.green300,
resolution.value,
displayResolution,
resolution.type === DataConditionType.GREATER
),
data: [],
Expand All @@ -245,14 +263,18 @@ export function useMetricDetectorThresholdSeries({
}

const valuesForMax = [
...thresholds.map(threshold => threshold.value),
...(resolution && isResolutionManual ? [resolution.value] : []),
...thresholds.map(threshold =>
isPercentageAggregate ? threshold.value / 100 : threshold.value
),
...(resolution && isResolutionManual
? [isPercentageAggregate ? resolution.value / 100 : resolution.value]
: []),
];
const maxValue = valuesForMax.length > 0 ? Math.max(...valuesForMax) : undefined;
return {maxValue, additionalSeries: additional};
}

// Other detection types not supported yet
return {maxValue: undefined, additionalSeries: additional};
}, [conditions, detectionType, comparisonSeries, theme]);
}, [conditions, detectionType, aggregate, comparisonSeries, theme]);
}
Loading