From 51527faaf55cbf36b692eaa1d36bee879b7b352c Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:01:55 +0100 Subject: [PATCH 1/3] Empty commit From 04991e585135031ceb020f67a90746b0bd509838 Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:15:28 +0100 Subject: [PATCH 2/3] Add more AI slop In particular: - make sure that the output in TUI mode can be marked and copied - add a generic rule about histograms that alert if >50% of observations are in the +Inf bucket - add actionable steps for the users and developers (separate fields) - fix too many extra lines in the markdown output --- internal/evaluator/cache.go | 5 +- internal/evaluator/evaluator_common.go | 19 ++- internal/evaluator/evaluator_test.go | 138 +++++++++++++++++++ internal/evaluator/gauge.go | 24 ++-- internal/evaluator/histogram.go | 178 +++++++++++++++++++++++++ internal/parser/prometheus.go | 56 ++++++++ internal/reporter/console.go | 37 ++++- internal/reporter/markdown.go | 36 ++++- internal/reporter/template.go | 14 +- internal/rules/types.go | 16 ++- internal/tui/styles.go | 3 +- internal/tui/tui.go | 6 +- internal/tui/view.go | 84 ++++++++++-- templates/markdown.tmpl | 51 ++++--- 14 files changed, 588 insertions(+), 79 deletions(-) diff --git a/internal/evaluator/cache.go b/internal/evaluator/cache.go index ac68e9a..bdc91c8 100644 --- a/internal/evaluator/cache.go +++ b/internal/evaluator/cache.go @@ -80,12 +80,13 @@ func EvaluateCacheHit(rule rules.Rule, metrics parser.MetricsData, loadLevel rul "hits": hits, "misses": misses, }) - if result.Status == rules.StatusYellow { + switch result.Status { + case rules.StatusYellow: result.Message = interpolate(rule.Messages.Yellow, hitRate, map[string]interface{}{ "hits": hits, "misses": misses, }) - } else if result.Status == rules.StatusRed { + case rules.StatusRed: result.Message = interpolate(rule.Messages.Red, hitRate, map[string]interface{}{ "hits": hits, "misses": misses, diff --git a/internal/evaluator/evaluator_common.go b/internal/evaluator/evaluator_common.go index f9abae4..ef14a36 100644 --- a/internal/evaluator/evaluator_common.go +++ b/internal/evaluator/evaluator_common.go @@ -108,8 +108,9 @@ func EvaluateAllRules(rulesList []rules.Rule, metrics parser.MetricsData, loadLe result = EvaluateCorrelation(rule, metrics, result) } - // Add remediation + // Add potential actions (user-facing) result.Remediation = getRemediation(rule, result.Status) + result.PotentialActionUser = result.Remediation result.Timestamp = time.Now() report.Results = append(report.Results, result) @@ -125,6 +126,22 @@ func EvaluateAllRules(rulesList []rules.Rule, metrics parser.MetricsData, loadLe } } + // Apply general histogram +Inf overflow rule to all histogram metrics + infOverflowResults := EvaluateHistogramInfOverflow(metrics, loadLevel) + for _, result := range infOverflowResults { + report.Results = append(report.Results, result) + + // Update summary + switch result.Status { + case rules.StatusRed: + report.Summary.RedCount++ + case rules.StatusYellow: + report.Summary.YellowCount++ + case rules.StatusGreen: + report.Summary.GreenCount++ + } + } + report.Summary.TotalAnalyzed = len(report.Results) return report diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index 43554ce..5746c9b 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -1,6 +1,7 @@ package evaluator import ( + "strings" "testing" "github.com/stackrox/sensor-metrics-analyzer/internal/parser" @@ -403,3 +404,140 @@ func TestIsRuleApplicable(t *testing.T) { }) } } + +func TestEvaluateHistogramInfOverflow(t *testing.T) { + tests := map[string]struct { + metrics parser.MetricsData + wantStatus map[string]rules.Status // metric name -> expected status + wantCount int // expected number of results + }{ + "should return red status when >50% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 100, Labels: map[string]string{"le": "+Inf"}}, // 70 in +Inf (70%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusRed, + }, + wantCount: 1, + }, + "should return yellow status when >25% but <=50% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 50, Labels: map[string]string{"le": "+Inf"}}, // 20 in +Inf (40%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusYellow, + }, + wantCount: 1, + }, + "should return green status when <=25% in +Inf": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + {Value: 30, Labels: map[string]string{"le": "1.0"}}, + {Value: 35, Labels: map[string]string{"le": "+Inf"}}, // 5 in +Inf (14.3%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "test_histogram (+Inf overflow check)": rules.StatusGreen, + }, + wantCount: 1, + }, + "should skip histogram without +Inf bucket": { + metrics: parser.MetricsData{ + "test_histogram_bucket": &parser.Metric{ + Name: "test_histogram_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "0.1"}}, + {Value: 20, Labels: map[string]string{"le": "0.5"}}, + }, + }, + }, + wantStatus: map[string]rules.Status{}, + wantCount: 0, + }, + "should handle multiple histograms": { + metrics: parser.MetricsData{ + "hist1_bucket": &parser.Metric{ + Name: "hist1_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "1.0"}}, + {Value: 100, Labels: map[string]string{"le": "+Inf"}}, // 90 in +Inf (90%) + }, + }, + "hist2_bucket": &parser.Metric{ + Name: "hist2_bucket", + Type: "histogram", + Values: []parser.MetricValue{ + {Value: 10, Labels: map[string]string{"le": "1.0"}}, + {Value: 15, Labels: map[string]string{"le": "+Inf"}}, // 5 in +Inf (33%) + }, + }, + }, + wantStatus: map[string]rules.Status{ + "hist1 (+Inf overflow check)": rules.StatusRed, + "hist2 (+Inf overflow check)": rules.StatusYellow, + }, + wantCount: 2, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + results := EvaluateHistogramInfOverflow(tt.metrics, rules.LoadLevelMedium) + + if len(results) != tt.wantCount { + t.Errorf("EvaluateHistogramInfOverflow() returned %d results, want %d", len(results), tt.wantCount) + } + + for _, result := range results { + wantStatus, exists := tt.wantStatus[result.RuleName] + if !exists { + t.Errorf("Unexpected result for metric %s", result.RuleName) + continue + } + + if result.Status != wantStatus { + t.Errorf("EvaluateHistogramInfOverflow() for %s = %v, want %v", result.RuleName, result.Status, wantStatus) + } + + // Verify message contains expected information + if result.Status != rules.StatusGreen { + if result.Message == "" { + t.Error("Message should not be empty for non-green status") + } + if !strings.Contains(result.Message, "Highest non-infinity bucket") { + t.Error("Message should contain 'Highest non-infinity bucket'") + } + if !strings.Contains(result.Message, "didn't expect") { + t.Error("Message should contain explanation about designer expectations") + } + } + } + }) + } +} diff --git a/internal/evaluator/gauge.go b/internal/evaluator/gauge.go index ddd8578..365124a 100644 --- a/internal/evaluator/gauge.go +++ b/internal/evaluator/gauge.go @@ -98,20 +98,18 @@ func interpolate(template string, value float64, extras map[string]interface{}) }) // Replace other placeholders from extras map - if extras != nil { - for key, val := range extras { - // Handle format specifiers - keyRe := regexp.MustCompile(`\{` + regexp.QuoteMeta(key) + `(?::[^}]+)?\}`) - result = keyRe.ReplaceAllStringFunc(result, func(match string) string { - if strings.Contains(match, ":") { - formatMatch := regexp.MustCompile(`:([^}]+)`) - if fm := formatMatch.FindStringSubmatch(match); len(fm) == 2 { - return fmt.Sprintf("%"+fm[1], val) - } + for key, val := range extras { + // Handle format specifiers + keyRe := regexp.MustCompile(`\{` + regexp.QuoteMeta(key) + `(?::[^}]+)?\}`) + result = keyRe.ReplaceAllStringFunc(result, func(match string) string { + if strings.Contains(match, ":") { + formatMatch := regexp.MustCompile(`:([^}]+)`) + if fm := formatMatch.FindStringSubmatch(match); len(fm) == 2 { + return fmt.Sprintf("%"+fm[1], val) } - return fmt.Sprintf("%v", val) - }) - } + } + return fmt.Sprintf("%v", val) + }) } return result diff --git a/internal/evaluator/histogram.go b/internal/evaluator/histogram.go index 0011a3c..e0c8a53 100644 --- a/internal/evaluator/histogram.go +++ b/internal/evaluator/histogram.go @@ -3,6 +3,8 @@ package evaluator import ( "fmt" "sort" + "strconv" + "strings" "time" "github.com/stackrox/sensor-metrics-analyzer/internal/parser" @@ -93,3 +95,179 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru return result } + +// EvaluateHistogramInfOverflow evaluates all histogram metrics for +Inf bucket overflow +// This is a general rule that applies to any histogram metric +func EvaluateHistogramInfOverflow(metrics parser.MetricsData, loadLevel rules.LoadLevel) []rules.EvaluationResult { + var results []rules.EvaluationResult + + // Get all histogram base names + histogramBases := metrics.GetHistogramBaseNames() + + for _, baseName := range histogramBases { + result := evaluateSingleHistogramInfOverflow(baseName, metrics) + if result != nil { + results = append(results, *result) + } + } + + return results +} + +// evaluateSingleHistogramInfOverflow evaluates a single histogram metric for +Inf overflow +// Handles multiple label combinations (series) by evaluating each separately and reporting the worst case +func evaluateSingleHistogramInfOverflow(baseName string, metrics parser.MetricsData) *rules.EvaluationResult { + result := &rules.EvaluationResult{ + RuleName: baseName + " (+Inf overflow check)", + Status: rules.StatusGreen, + Details: make(map[string]interface{}), + Timestamp: time.Now(), + } + + // Get histogram buckets + bucketMetricName := baseName + "_bucket" + bucketMetric, exists := metrics.GetMetric(bucketMetricName) + if !exists || len(bucketMetric.Values) == 0 { + return nil // Skip if no buckets found + } + + // Group buckets by label combination (excluding "le" label) + // Each label combination represents a separate time series + seriesBuckets := make(map[string][]parser.MetricValue) + for _, v := range bucketMetric.Values { + // Create a key from all labels except "le" + seriesKey := getSeriesKey(v.Labels) + seriesBuckets[seriesKey] = append(seriesBuckets[seriesKey], v) + } + + // Track worst case across all series + var worstInfPercentage float64 + var worstInfObservations float64 + var worstTotalCount float64 + var worstHighestFiniteLe float64 + var worstStatus rules.Status + + hasAnyData := false + + // Evaluate each series separately + for _, buckets := range seriesBuckets { + // Find +Inf bucket and highest finite bucket for this series + var infCount float64 + var highestFiniteLe float64 + var highestFiniteCount float64 + hasInf := false + hasFinite := false + + for _, v := range buckets { + if leStr, exists := v.Labels["le"]; exists { + if leStr == "+Inf" { + infCount = v.Value + hasInf = true + } else if le, err := strconv.ParseFloat(leStr, 64); err == nil { + if !hasFinite || le > highestFiniteLe { + highestFiniteLe = le + highestFiniteCount = v.Value + hasFinite = true + } + } + } + } + + if !hasInf || !hasFinite || infCount == 0 { + continue + } + + hasAnyData = true + + // Calculate the percentage of observations in +Inf bucket for this series + // Observations in +Inf = totalCount - highestFiniteCount + infObservations := infCount - highestFiniteCount + if infObservations < 0 { + // This can happen if there are data inconsistencies, skip this series + continue + } + infPercentage := (infObservations / infCount) * 100.0 + + // Track worst case + if infPercentage > worstInfPercentage { + worstInfPercentage = infPercentage + worstInfObservations = infObservations + worstTotalCount = infCount + worstHighestFiniteLe = highestFiniteLe + + // Determine status for this series + if infPercentage > 50.0 { + worstStatus = rules.StatusRed + } else if infPercentage > 25.0 { + worstStatus = rules.StatusYellow + } else { + worstStatus = rules.StatusGreen + } + } + } + + if !hasAnyData { + return nil + } + + result.Status = worstStatus + result.Details["Total Number of Observations"] = formatHumanNumber(worstTotalCount) + result.Details["Observations in +Inf bucket"] = formatHumanNumber(worstInfObservations) + result.Details["Percentage of observations in +Inf bucket"] = formatHumanNumber(worstInfPercentage) + " %" + result.Details["Highest non-infinity bucket"] = formatHumanNumber(worstHighestFiniteLe) + " units" + + // Build message based on worst case + result.Message = fmt.Sprintf("%s%% of observations in +Inf bucket (acceptable). Highest non-infinity bucket: %s", + formatHumanNumber(worstInfPercentage), formatHumanNumber(worstHighestFiniteLe)) + if worstInfPercentage > 25.0 { + result.Message = fmt.Sprintf("%s%% of observations are in +Inf bucket (%s out of %s). "+ + "This indicates the metric designer likely didn't expect processing durations to be so high. "+ + "Highest non-infinity bucket: %s", + formatHumanNumber(worstInfPercentage), + formatHumanNumber(worstInfObservations), + formatHumanNumber(worstTotalCount), + formatHumanNumber(worstHighestFiniteLe)) + result.PotentialActionUser = fmt.Sprintf("Further investigation is required to understand why values exceed %s. "+ + "Check if there are other alerts for this specific metric with more precise context.", formatHumanNumber(worstHighestFiniteLe)) + result.PotentialActionDeveloper = "Review code paths and metric instrumentation to confirm whether observed latencies are expected." + } + return result +} + +// getSeriesKey creates a key from labels excluding "le" to group buckets by series +func getSeriesKey(labels map[string]string) string { + var keys []string + for k, v := range labels { + if k != "le" { + keys = append(keys, k+"="+v) + } + } + sort.Strings(keys) + return strings.Join(keys, ",") +} + +func formatHumanNumber(value float64) string { + raw := strconv.FormatFloat(value, 'f', -1, 64) + sign := "" + if strings.HasPrefix(raw, "-") { + sign = "-" + raw = strings.TrimPrefix(raw, "-") + } + parts := strings.SplitN(raw, ".", 2) + intPart := parts[0] + var fracPart string + if len(parts) == 2 && parts[1] != "" { + fracPart = parts[1] + } + var grouped strings.Builder + for i, r := range intPart { + if i > 0 && (len(intPart)-i)%3 == 0 { + grouped.WriteString(" ") + } + grouped.WriteRune(r) + } + if fracPart != "" { + return sign + grouped.String() + "." + fracPart + } + return sign + grouped.String() +} diff --git a/internal/parser/prometheus.go b/internal/parser/prometheus.go index 75b75a3..2d03043 100644 --- a/internal/parser/prometheus.go +++ b/internal/parser/prometheus.go @@ -4,6 +4,7 @@ import ( "bufio" "os" "regexp" + "sort" "strconv" "strings" ) @@ -195,6 +196,32 @@ func (m *Metric) GetHistogramBuckets() []HistogramBucket { return buckets } +// GetHistogramInfBucketCount returns the count from the +Inf bucket +func (m *Metric) GetHistogramInfBucketCount() (float64, bool) { + for _, v := range m.Values { + if leStr, exists := v.Labels["le"]; exists && leStr == "+Inf" { + return v.Value, true + } + } + return 0, false +} + +// GetHistogramHighestFiniteBucket returns the highest finite bucket's le value and count +func (m *Metric) GetHistogramHighestFiniteBucket() (le float64, count float64, found bool) { + buckets := m.GetHistogramBuckets() + if len(buckets) == 0 { + return 0, 0, false + } + + // Sort buckets by le value to find the highest + sort.Slice(buckets, func(i, j int) bool { + return buckets[i].Le < buckets[j].Le + }) + + highest := buckets[len(buckets)-1] + return highest.Le, highest.Count, true +} + // GetHistogramSum returns the _sum value for a histogram metric func (md MetricsData) GetHistogramSum(baseName string) (float64, bool) { sumMetric := baseName + "_sum" @@ -242,3 +269,32 @@ func (md MetricsData) DetectACSVersion() (string, bool) { return "", false } + +// GetHistogramBaseNames returns a list of base histogram metric names (without _bucket, _sum, _count suffixes) +func (md MetricsData) GetHistogramBaseNames() []string { + histogramBases := make(map[string]bool) + + for metricName, metric := range md { + // Check if this is a histogram type metric + if metric.Type == "histogram" { + // Extract base name by removing _bucket, _sum, _count suffixes + baseName := metricName + if strings.HasSuffix(baseName, "_bucket") { + baseName = strings.TrimSuffix(baseName, "_bucket") + } else if strings.HasSuffix(baseName, "_sum") { + baseName = strings.TrimSuffix(baseName, "_sum") + } else if strings.HasSuffix(baseName, "_count") { + baseName = strings.TrimSuffix(baseName, "_count") + } + histogramBases[baseName] = true + } + } + + // Convert map to slice and sort for consistent output + result := make([]string, 0, len(histogramBases)) + for baseName := range histogramBases { + result = append(result, baseName) + } + sort.Strings(result) + return result +} diff --git a/internal/reporter/console.go b/internal/reporter/console.go index 3b5bb1d..13f2c90 100644 --- a/internal/reporter/console.go +++ b/internal/reporter/console.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "slices" "strings" "github.com/fatih/color" @@ -65,8 +66,22 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.New(color.Bold).Sprintf("%s\n", r.RuleName)) result.WriteString(color.RedString(" Status: RED\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) - if r.Remediation != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Recommended Action: %s\n", r.Remediation)) + if len(r.Details) > 0 { + result.WriteString(" Details:\n") + keys := make([]string, 0, len(r.Details)) + for k := range r.Details { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + result.WriteString(fmt.Sprintf(" %s: %v\n", k, r.Details[k])) + } + } + if r.PotentialActionUser != "" { + result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action: %s\n", r.PotentialActionUser)) + } + if r.PotentialActionDeveloper != "" { + result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action (developer): %s\n", r.PotentialActionDeveloper)) } result.WriteString("\n") } @@ -80,8 +95,22 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.New(color.Bold).Sprintf("%s\n", r.RuleName)) result.WriteString(color.YellowString(" Status: YELLOW\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) - if r.Remediation != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Recommended Action: %s\n", r.Remediation)) + if len(r.Details) > 0 { + result.WriteString(" Details:\n") + keys := make([]string, 0, len(r.Details)) + for k := range r.Details { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + result.WriteString(fmt.Sprintf(" %s: %v\n", k, r.Details[k])) + } + } + if r.PotentialActionUser != "" { + result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action: %s\n", r.PotentialActionUser)) + } + if r.PotentialActionDeveloper != "" { + result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action (developer): %s\n", r.PotentialActionDeveloper)) } result.WriteString("\n") } diff --git a/internal/reporter/markdown.go b/internal/reporter/markdown.go index 0e743af..ee9247f 100644 --- a/internal/reporter/markdown.go +++ b/internal/reporter/markdown.go @@ -47,8 +47,22 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "### " + r.RuleName + "\n\n" result += "**Status:** RED\n" result += "**Message:** " + r.Message + "\n" - if r.Remediation != "" { - result += "**Recommended Action:** " + r.Remediation + "\n" + if len(r.Details) > 0 { + result += "**Details:**\n" + keys := make([]string, 0, len(r.Details)) + for k := range r.Details { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + result += "- " + k + ": " + fmt.Sprintf("%v", r.Details[k]) + "\n" + } + } + if r.PotentialActionUser != "" { + result += "**Potential action:** " + r.PotentialActionUser + "\n" + } + if r.PotentialActionDeveloper != "" { + result += "**Potential action (developer):** " + r.PotentialActionDeveloper + "\n" } result += "\n" } @@ -62,8 +76,22 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "### " + r.RuleName + "\n\n" result += "**Status:** YELLOW\n" result += "**Message:** " + r.Message + "\n" - if r.Remediation != "" { - result += "**Recommended Action:** " + r.Remediation + "\n" + if len(r.Details) > 0 { + result += "**Details:**\n" + keys := make([]string, 0, len(r.Details)) + for k := range r.Details { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + result += "- " + k + ": " + fmt.Sprintf("%v", r.Details[k]) + "\n" + } + } + if r.PotentialActionUser != "" { + result += "**Potential action:** " + r.PotentialActionUser + "\n" + } + if r.PotentialActionDeveloper != "" { + result += "**Potential action (developer):** " + r.PotentialActionDeveloper + "\n" } result += "\n" } diff --git a/internal/reporter/template.go b/internal/reporter/template.go index a7bb5f8..ae054ee 100644 --- a/internal/reporter/template.go +++ b/internal/reporter/template.go @@ -73,5 +73,17 @@ func GenerateMarkdownFromTemplate(report rules.AnalysisReport, templatePath stri return "", err } - return ExecuteTemplate(tmpl, report) + data := struct { + rules.AnalysisReport + RedResults []rules.EvaluationResult + YellowResults []rules.EvaluationResult + GreenResults []rules.EvaluationResult + }{ + AnalysisReport: report, + RedResults: filterByStatus(report.Results, rules.StatusRed), + YellowResults: filterByStatus(report.Results, rules.StatusYellow), + GreenResults: filterByStatus(report.Results, rules.StatusGreen), + } + + return ExecuteTemplate(tmpl, data) } diff --git a/internal/rules/types.go b/internal/rules/types.go index a0a4e3a..50b7c17 100644 --- a/internal/rules/types.go +++ b/internal/rules/types.go @@ -189,13 +189,15 @@ type LoadDetectionThreshold struct { // EvaluationResult represents the result of evaluating a rule type EvaluationResult struct { - RuleName string - Status Status - Message string - Value float64 - Details map[string]interface{} - Remediation string // Suggested remediation action (if available) - Timestamp time.Time + RuleName string + Status Status + Message string + Value float64 + Details map[string]interface{} + Remediation string // Legacy field (use PotentialActionUser/Developer) + PotentialActionUser string + PotentialActionDeveloper string + Timestamp time.Time } // AnalysisReport contains all evaluation results diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 78f4cfe..c364a28 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -122,8 +122,7 @@ var ( remediationStyle = lipgloss.NewStyle(). Foreground(colorYellow). - Italic(true). - MarginTop(1) + Italic(true) // Help bar helpStyle = lipgloss.NewStyle(). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4e708f0..5617823 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -25,8 +25,7 @@ func Run(report rules.AnalysisReport) error { p := tea.NewProgram( model, - tea.WithAltScreen(), // Use alternate screen buffer - tea.WithMouseCellMotion(), // Enable mouse support + // Avoid alternate screen and mouse capture so text is copyable ) if _, err := p.Run(); err != nil { @@ -43,8 +42,7 @@ func RunWithOutput(report rules.AnalysisReport) (*Model, error) { p := tea.NewProgram( model, - tea.WithAltScreen(), - tea.WithMouseCellMotion(), + // Avoid alternate screen and mouse capture so text is copyable ) finalModel, err := p.Run() diff --git a/internal/tui/view.go b/internal/tui/view.go index a3385e8..0b100e1 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "sort" "strings" "github.com/charmbracelet/lipgloss" @@ -210,10 +211,21 @@ func (m Model) viewDetail() string { } detail.WriteString("\n\n") - // Message - detail.WriteString(detailLabelStyle.Render("Message:\n")) - detail.WriteString(detailValueStyle.Render(" " + result.Message)) - detail.WriteString("\n\n") + // Message (wrapped to screen width) + detail.WriteString(detailLabelStyle.Render("Message:")) + detail.WriteString("\n") + messageWidth := 70 + if m.width > 0 { + messageWidth = m.width - 10 + if messageWidth < 40 { + messageWidth = 40 + } + } + wrappedMessage := wordWrap(result.Message, messageWidth) + for _, line := range strings.Split(wrappedMessage, "\n") { + detail.WriteString(fmt.Sprintf(" %s\n", line)) + } + detail.WriteString("\n") // Value if result.Value != 0 { @@ -222,22 +234,66 @@ func (m Model) viewDetail() string { detail.WriteString("\n\n") } - // Details map + // Details map - display all details in plain text format for easy copying if len(result.Details) > 0 { - detail.WriteString(detailLabelStyle.Render("Details:\n")) - for k, v := range result.Details { - detail.WriteString(fmt.Sprintf(" %s: %v\n", k, v)) + detail.WriteString(detailLabelStyle.Render("Details:")) + detail.WriteString("\n") + + // Sort keys for consistent ordering + keys := make([]string, 0, len(result.Details)) + for k := range result.Details { + keys = append(keys, k) + } + sort.Strings(keys) + + // Display details in plain text format (no ANSI codes) for easy selection/copying + for _, k := range keys { + v := result.Details[k] + // Format the value nicely + var formattedValue string + switch val := v.(type) { + case float64: + // Format floats with appropriate precision + if val >= 1000000 { + formattedValue = fmt.Sprintf("%.0f", val) + } else if val >= 1000 { + formattedValue = fmt.Sprintf("%.0f", val) + } else if val >= 1 { + formattedValue = fmt.Sprintf("%.2f", val) + } else { + formattedValue = fmt.Sprintf("%.4f", val) + } + case int: + formattedValue = fmt.Sprintf("%d", val) + case int64: + formattedValue = fmt.Sprintf("%d", val) + default: + // For other types, convert to string as-is (no truncation) + formattedValue = fmt.Sprintf("%v", v) + } + + // Plain text format: key: value (completely plain text for easy copying) + detail.WriteString(fmt.Sprintf(" %s: %s\n", k, formattedValue)) } detail.WriteString("\n") } - // Remediation (if available and status is RED or YELLOW) - if result.Remediation != "" && (result.Status == "RED" || result.Status == "YELLOW") { - detail.WriteString(detailLabelStyle.Render("💡 Remediation:\n")) - // Word wrap the remediation text - wrapped := wordWrap(result.Remediation, 60) + // Potential actions (user/developer) + if result.PotentialActionUser != "" && (result.Status == "RED" || result.Status == "YELLOW") { + detail.WriteString(detailLabelStyle.Render("Potential action:")) + detail.WriteString("\n") + wrapped := wordWrap(result.PotentialActionUser, 60) + for _, line := range strings.Split(wrapped, "\n") { + detail.WriteString(remediationStyle.Render(fmt.Sprintf(" %s", line))) + detail.WriteString("\n") + } + } + if result.PotentialActionDeveloper != "" && (result.Status == "RED" || result.Status == "YELLOW") { + detail.WriteString(detailLabelStyle.Render("Potential action (developer):")) + detail.WriteString("\n") + wrapped := wordWrap(result.PotentialActionDeveloper, 60) for _, line := range strings.Split(wrapped, "\n") { - detail.WriteString(remediationStyle.Render(" " + line)) + detail.WriteString(remediationStyle.Render(fmt.Sprintf(" %s", line))) detail.WriteString("\n") } } diff --git a/templates/markdown.tmpl b/templates/markdown.tmpl index 13192bb..a909ae2 100644 --- a/templates/markdown.tmpl +++ b/templates/markdown.tmpl @@ -11,47 +11,44 @@ - 🟡 **YELLOW:** {{.Summary.YellowCount}} metrics - 🟢 **GREEN:** {{.Summary.GreenCount}} metrics -{{if gt .Summary.RedCount 0}} +{{ if gt (len .RedResults) 0 }} + ## 🔴 Critical Issues -{{range .Results}} -{{if eq .Status "RED"}} +{{ range .RedResults }} ### {{.RuleName}} - **Status:** RED **Message:** {{.Message}} -{{if .Remediation}} -**Recommended Action:** {{.Remediation}} -{{end}} +{{ if .PotentialActionUser }} +**Potential action:** {{.PotentialActionUser}} +{{ end }} +{{ if .PotentialActionDeveloper }} +**Potential action (developer):** {{.PotentialActionDeveloper}} +{{ end }} + +{{ end }} -{{end}} -{{end}} -{{end}} +{{ if gt (len .YellowResults) 0 }} -{{if gt .Summary.YellowCount 0}} ## 🟡 Warnings -{{range .Results}} -{{if eq .Status "YELLOW"}} +{{ range .YellowResults }} ### {{.RuleName}} - **Status:** YELLOW **Message:** {{.Message}} -{{if .Remediation}} -**Recommended Action:** {{.Remediation}} -{{end}} +{{ if .PotentialActionUser }} +**Potential action:** {{.PotentialActionUser}} +{{ end }} +{{ if .PotentialActionDeveloper }} +**Potential action (developer):** {{.PotentialActionDeveloper}} +{{ end }} -{{end}} -{{end}} -{{end}} +{{ end }} -{{if gt .Summary.GreenCount 0}} -## 🟢 Healthy Metrics +{{ if gt (len .GreenResults) 0 }} -{{range .Results}} -{{if eq .Status "GREEN"}} +## 🟢 Healthy Metrics +{{ range .GreenResults }} - **{{.RuleName}}:** {{.Message}} -{{end}} -{{end}} -{{end}} +{{ end }} From 8755407f4dcc9df89596718073fb2970eb0b3f4a Mon Sep 17 00:00:00 2001 From: Piotr Rygielski <114479+vikin91@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:32:19 +0100 Subject: [PATCH 3/3] Make details a slice instead of map --- internal/evaluator/cache.go | 2 +- internal/evaluator/composite.go | 4 ++-- internal/evaluator/gauge.go | 2 +- internal/evaluator/histogram.go | 29 ++++++++++++++++-------- internal/evaluator/percentage.go | 2 +- internal/evaluator/queue.go | 2 +- internal/reporter/console.go | 39 +++++++++++++++----------------- internal/reporter/markdown.go | 18 ++++----------- internal/rules/types.go | 2 +- internal/tui/view.go | 37 ++---------------------------- 10 files changed, 50 insertions(+), 87 deletions(-) diff --git a/internal/evaluator/cache.go b/internal/evaluator/cache.go index bdc91c8..8212b3e 100644 --- a/internal/evaluator/cache.go +++ b/internal/evaluator/cache.go @@ -13,7 +13,7 @@ func EvaluateCacheHit(rule rules.Rule, metrics parser.MetricsData, loadLevel rul result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/evaluator/composite.go b/internal/evaluator/composite.go index 6edce04..2d2d4c4 100644 --- a/internal/evaluator/composite.go +++ b/internal/evaluator/composite.go @@ -14,7 +14,7 @@ func EvaluateComposite(rule rules.Rule, metrics parser.MetricsData, loadLevel ru result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -33,7 +33,7 @@ func EvaluateComposite(rule rules.Rule, metrics parser.MetricsData, loadLevel ru } value, _ := metric.GetSingleValue() metricValues[metricDef.Name] = value - result.Details[metricDef.Name] = value + result.Details = append(result.Details, fmt.Sprintf("%s: %.3f", metricDef.Name, value)) } // Evaluate checks in order diff --git a/internal/evaluator/gauge.go b/internal/evaluator/gauge.go index 365124a..15366d0 100644 --- a/internal/evaluator/gauge.go +++ b/internal/evaluator/gauge.go @@ -15,7 +15,7 @@ func EvaluateGauge(rule rules.Rule, metrics parser.MetricsData, loadLevel rules. result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/evaluator/histogram.go b/internal/evaluator/histogram.go index e0c8a53..fed7ace 100644 --- a/internal/evaluator/histogram.go +++ b/internal/evaluator/histogram.go @@ -16,7 +16,7 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -65,9 +65,11 @@ func EvaluateHistogram(rule rules.Rule, metrics parser.MetricsData, loadLevel ru } result.Value = p95 - result.Details["p95"] = p95 - result.Details["p99"] = p99 - result.Details["count"] = totalCount + result.Details = append(result.Details, + fmt.Sprintf("p95: %.3f", p95), + fmt.Sprintf("p99: %.3f", p99), + fmt.Sprintf("count: %.0f", totalCount), + ) // Select thresholds based on load level thresholds := selectThresholds(rule, loadLevel) @@ -120,7 +122,7 @@ func evaluateSingleHistogramInfOverflow(baseName string, metrics parser.MetricsD result := &rules.EvaluationResult{ RuleName: baseName + " (+Inf overflow check)", Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } @@ -211,10 +213,17 @@ func evaluateSingleHistogramInfOverflow(baseName string, metrics parser.MetricsD } result.Status = worstStatus - result.Details["Total Number of Observations"] = formatHumanNumber(worstTotalCount) - result.Details["Observations in +Inf bucket"] = formatHumanNumber(worstInfObservations) - result.Details["Percentage of observations in +Inf bucket"] = formatHumanNumber(worstInfPercentage) + " %" - result.Details["Highest non-infinity bucket"] = formatHumanNumber(worstHighestFiniteLe) + " units" + if baseMetric, ok := metrics.GetMetric(baseName); ok && baseMetric.Help != "" { + result.Details = append(result.Details, "Metric Description: "+baseMetric.Help) + } else if bucketMetric, ok := metrics.GetMetric(baseName + "_bucket"); ok && bucketMetric.Help != "" { + result.Details = append(result.Details, "Metric Description: "+bucketMetric.Help) + } + result.Details = append(result.Details, + "Total Number of Observations: "+formatHumanNumber(worstTotalCount)+" unit", + "Observations in +Inf bucket: "+formatHumanNumber(worstInfObservations)+" unit", + "Percentage of observations in +Inf bucket: "+formatHumanNumber(worstInfPercentage)+" %", + "Highest non-infinity bucket: "+formatHumanNumber(worstHighestFiniteLe)+" unit", + ) // Build message based on worst case result.Message = fmt.Sprintf("%s%% of observations in +Inf bucket (acceptable). Highest non-infinity bucket: %s", @@ -247,7 +256,7 @@ func getSeriesKey(labels map[string]string) string { } func formatHumanNumber(value float64) string { - raw := strconv.FormatFloat(value, 'f', -1, 64) + raw := strconv.FormatFloat(value, 'f', 2, 64) sign := "" if strings.HasPrefix(raw, "-") { sign = "-" diff --git a/internal/evaluator/percentage.go b/internal/evaluator/percentage.go index 54b15f2..873358b 100644 --- a/internal/evaluator/percentage.go +++ b/internal/evaluator/percentage.go @@ -13,7 +13,7 @@ func EvaluatePercentage(rule rules.Rule, metrics parser.MetricsData, loadLevel r result := rules.EvaluationResult{ RuleName: rule.DisplayName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/evaluator/queue.go b/internal/evaluator/queue.go index ab905aa..c887d23 100644 --- a/internal/evaluator/queue.go +++ b/internal/evaluator/queue.go @@ -13,7 +13,7 @@ func EvaluateQueue(rule rules.Rule, metrics parser.MetricsData, loadLevel rules. result := rules.EvaluationResult{ RuleName: rule.MetricName, Status: rules.StatusGreen, - Details: make(map[string]interface{}), + Details: []string{}, Timestamp: time.Now(), } diff --git a/internal/reporter/console.go b/internal/reporter/console.go index 13f2c90..5f05075 100644 --- a/internal/reporter/console.go +++ b/internal/reporter/console.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "slices" "strings" "github.com/fatih/color" @@ -67,21 +66,20 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.RedString(" Status: RED\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) if len(r.Details) > 0 { - result.WriteString(" Details:\n") - keys := make([]string, 0, len(r.Details)) - for k := range r.Details { - keys = append(keys, k) - } - slices.Sort(keys) - for _, k := range keys { - result.WriteString(fmt.Sprintf(" %s: %v\n", k, r.Details[k])) + result.WriteString(color.New(color.FgYellow).Sprint(" Details:\n")) + for _, detail := range r.Details { + result.WriteString(fmt.Sprintf(" %s\n", detail)) } } if r.PotentialActionUser != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action: %s\n", r.PotentialActionUser)) + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action:"), + r.PotentialActionUser)) } if r.PotentialActionDeveloper != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action (developer): %s\n", r.PotentialActionDeveloper)) + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action (developer):"), + r.PotentialActionDeveloper)) } result.WriteString("\n") } @@ -96,21 +94,20 @@ func GenerateConsole(report rules.AnalysisReport) string { result.WriteString(color.YellowString(" Status: YELLOW\n")) result.WriteString(fmt.Sprintf(" Message: %s\n", r.Message)) if len(r.Details) > 0 { - result.WriteString(" Details:\n") - keys := make([]string, 0, len(r.Details)) - for k := range r.Details { - keys = append(keys, k) - } - slices.Sort(keys) - for _, k := range keys { - result.WriteString(fmt.Sprintf(" %s: %v\n", k, r.Details[k])) + result.WriteString(color.New(color.FgYellow).Sprint(" Details:\n")) + for _, detail := range r.Details { + result.WriteString(fmt.Sprintf(" %s\n", detail)) } } if r.PotentialActionUser != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action: %s\n", r.PotentialActionUser)) + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action:"), + r.PotentialActionUser)) } if r.PotentialActionDeveloper != "" { - result.WriteString(color.New(color.FgYellow).Sprintf(" Potential action (developer): %s\n", r.PotentialActionDeveloper)) + result.WriteString(fmt.Sprintf(" %s %s\n", + color.New(color.FgYellow).Sprint("Potential action (developer):"), + r.PotentialActionDeveloper)) } result.WriteString("\n") } diff --git a/internal/reporter/markdown.go b/internal/reporter/markdown.go index ee9247f..0c1e7dd 100644 --- a/internal/reporter/markdown.go +++ b/internal/reporter/markdown.go @@ -49,13 +49,8 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "**Message:** " + r.Message + "\n" if len(r.Details) > 0 { result += "**Details:**\n" - keys := make([]string, 0, len(r.Details)) - for k := range r.Details { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - result += "- " + k + ": " + fmt.Sprintf("%v", r.Details[k]) + "\n" + for _, detail := range r.Details { + result += "- " + detail + "\n" } } if r.PotentialActionUser != "" { @@ -78,13 +73,8 @@ func generateMarkdownDefault(report rules.AnalysisReport) string { result += "**Message:** " + r.Message + "\n" if len(r.Details) > 0 { result += "**Details:**\n" - keys := make([]string, 0, len(r.Details)) - for k := range r.Details { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - result += "- " + k + ": " + fmt.Sprintf("%v", r.Details[k]) + "\n" + for _, detail := range r.Details { + result += "- " + detail + "\n" } } if r.PotentialActionUser != "" { diff --git a/internal/rules/types.go b/internal/rules/types.go index 50b7c17..7eb8482 100644 --- a/internal/rules/types.go +++ b/internal/rules/types.go @@ -193,7 +193,7 @@ type EvaluationResult struct { Status Status Message string Value float64 - Details map[string]interface{} + Details []string Remediation string // Legacy field (use PotentialActionUser/Developer) PotentialActionUser string PotentialActionDeveloper string diff --git a/internal/tui/view.go b/internal/tui/view.go index 0b100e1..d95eeda 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "sort" "strings" "github.com/charmbracelet/lipgloss" @@ -239,41 +238,9 @@ func (m Model) viewDetail() string { detail.WriteString(detailLabelStyle.Render("Details:")) detail.WriteString("\n") - // Sort keys for consistent ordering - keys := make([]string, 0, len(result.Details)) - for k := range result.Details { - keys = append(keys, k) - } - sort.Strings(keys) - // Display details in plain text format (no ANSI codes) for easy selection/copying - for _, k := range keys { - v := result.Details[k] - // Format the value nicely - var formattedValue string - switch val := v.(type) { - case float64: - // Format floats with appropriate precision - if val >= 1000000 { - formattedValue = fmt.Sprintf("%.0f", val) - } else if val >= 1000 { - formattedValue = fmt.Sprintf("%.0f", val) - } else if val >= 1 { - formattedValue = fmt.Sprintf("%.2f", val) - } else { - formattedValue = fmt.Sprintf("%.4f", val) - } - case int: - formattedValue = fmt.Sprintf("%d", val) - case int64: - formattedValue = fmt.Sprintf("%d", val) - default: - // For other types, convert to string as-is (no truncation) - formattedValue = fmt.Sprintf("%v", v) - } - - // Plain text format: key: value (completely plain text for easy copying) - detail.WriteString(fmt.Sprintf(" %s: %s\n", k, formattedValue)) + for _, detailLine := range result.Details { + detail.WriteString(fmt.Sprintf(" %s\n", detailLine)) } detail.WriteString("\n") }