pkg/degradation-detector/degradationDetector.go (149 lines of code) (raw):
package degradation_detector
import (
"log/slog"
"math"
"github.com/AndreyAkinshin/pragmastat/go/v3"
"github.com/JetBrains/ij-perf-report-aggregator/pkg/degradation-detector/statistic"
)
type Degradation struct {
Build string
timestamp int64
medianValues CenterValues
IsDegradation bool
}
type CenterValues struct {
previousValue float64
newValue float64
}
type analysisSettings interface {
GetReportType() ReportType
GetMinimumSegmentLength() int
GetMedianDifferenceThreshold() float64
GetEffectSizeThreshold() float64
GetDaysToCheckMissing() int
GetAnalysisKind() AnalysisKind
GetThresholdMode() ThresholdMode
GetThresholdValue() float64
}
func (v CenterValues) PercentageChange() float64 {
return math.Abs((v.newValue - v.previousValue) / v.previousValue * 100)
}
func detectDegradations(values []int, builds []string, timestamps []int64, analysisSettings analysisSettings) []Degradation {
degradations := make([]Degradation, 0)
if analysisSettings.GetAnalysisKind() == ThresholdAnalysis {
return detectThresholdExceed(values, builds, timestamps, analysisSettings)
}
minimumSegmentLength := analysisSettings.GetMinimumSegmentLength()
if minimumSegmentLength == 0 {
minimumSegmentLength = 5
}
medianDifference := analysisSettings.GetMedianDifferenceThreshold()
if medianDifference == 0 {
medianDifference = 10
}
effectSizeThreshold := analysisSettings.GetEffectSizeThreshold()
if effectSizeThreshold == 0 {
effectSizeThreshold = 2
}
changePoints := statistic.GetChangePointIndexes(values, min(5, len(values)/2))
segments := GetSegmentsBetweenChangePoints(changePoints, values)
if len(segments) < 2 {
slog.Debug("no significant change points were detected")
return degradations
}
lastSegment := segments[len(segments)-1]
if len(lastSegment) < minimumSegmentLength {
slog.Info("last segment is too short")
return degradations
}
skippedSegments := 0
for i := len(segments) - 2; i >= 0 && skippedSegments < 4; i-- {
if len(segments[i]) < minimumSegmentLength {
skippedSegments++
continue
}
currentCenter, err := pragmastat.Center(lastSegment)
if err != nil {
skippedSegments++
continue
}
previousCenter, err := pragmastat.Center(segments[i])
if err != nil {
skippedSegments++
continue
}
ratio := currentCenter / previousCenter
percentageChange := math.Abs((ratio - 1) * 100)
absoluteChange := math.Abs(currentCenter - previousCenter)
if absoluteChange < 10 || percentageChange < medianDifference {
break
}
es := statistic.EffectSize(lastSegment, segments[i])
if es < effectSizeThreshold {
break
}
isDegradation := currentCenter > previousCenter
reportType := analysisSettings.GetReportType()
if !isDegradation && reportType == DegradationEvent {
break
}
if isDegradation && reportType == ImprovementEvent {
break
}
index := changePoints[len(segments)-2]
degradations = append(degradations, Degradation{
Build: builds[index],
timestamp: timestamps[index],
medianValues: CenterValues{previousValue: previousCenter, newValue: currentCenter},
IsDegradation: isDegradation,
})
break
}
return degradations
}
// detectThresholdExceed emits a degradation when the latest value crosses the configured threshold
// according to the selected ThresholdMode. For GreaterThan mode, strictly greater (>) is used; for
// LessThan mode, strictly less (<) is used.
func detectThresholdExceed(values []int, builds []string, timestamps []int64, s analysisSettings) []Degradation {
result := make([]Degradation, 0, 1)
if len(values) == 0 || len(builds) == 0 || len(timestamps) == 0 {
return result
}
lastIdx := len(values) - 1
last := float64(values[lastIdx])
threshold := s.GetThresholdValue()
meets := false
switch s.GetThresholdMode() {
case ThresholdGreaterThan:
meets = last > threshold
case ThresholdLessThan:
meets = last < threshold
}
if !meets {
return result
}
// Treat exceeding threshold as degradation event; consumer can filter by ReportType if needed
var previous float64
if lastIdx > 0 {
previous = float64(values[lastIdx-1])
} else {
previous = threshold
}
result = append(result, Degradation{
Build: builds[lastIdx],
timestamp: timestamps[lastIdx],
medianValues: CenterValues{previousValue: previous, newValue: last},
IsDegradation: true,
})
return result
}
func GetSegmentsBetweenChangePoints(changePoints []int, values []int) [][]int {
segments := make([][]int, 0, len(changePoints)+1)
prevChangePoint := 0
for _, changePoint := range changePoints {
segment := values[prevChangePoint:changePoint]
segments = append(segments, segment)
prevChangePoint = changePoint
}
if prevChangePoint < len(values) {
segment := values[prevChangePoint:]
segments = append(segments, segment)
}
return segments
}