diff --git a/demo/remetric.gif b/demo/remetric.gif index b025124..892e92d 100644 Binary files a/demo/remetric.gif and b/demo/remetric.gif differ diff --git a/docs/getting-started.md b/docs/getting-started.md index 08328d4..f90b793 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,14 +41,14 @@ The terminal output looks like this: ▸ labelpattern... done (8ms) ▸ unusedmetrics... done (13ms) ▸ alerthygiene... done (7ms) -┌──────────┬────────────────────┬──────────┬────────┬────────┬────────────────┐ -│ SEVERITY │ METRIC │ LABEL │ SERIES │ UNIQUE │ EST. REDUCTION │ -├──────────┼────────────────────┼──────────┼────────┼────────┼────────────────┤ -│ CRITICAL │ app_requests_total │ trace_id │ 500 │ 500 │ ~499 │ -│ MEDIUM │ │ user_id │ 500 │ 500 │ ~499 │ -│ MEDIUM │ │ trace_id │ 500 │ 500 │ ~499 │ -│ MEDIUM │ │ │ 0 │ 0 │ ~0 │ -└──────────┴────────────────────┴──────────┴────────┴────────┴────────────────┘ +┌──────────┬───────────────────────────────┬────────────────────┬──────────┬────────┬────────┬────────────────┐ +│ SEVERITY │ CLASS │ METRIC │ LABEL │ SERIES │ UNIQUE │ EST. REDUCTION │ +├──────────┼───────────────────────────────┼────────────────────┼──────────┼────────┼────────┼────────────────┤ +│ CRITICAL │ hot-label │ app_requests_total │ trace_id │ 500 │ 500 │ ~499 │ +│ MEDIUM │ label-pattern-overly-granular │ │ user_id │ 500 │ 500 │ ~499 │ +│ MEDIUM │ label-pattern-overly-granular │ │ trace_id │ 500 │ 500 │ ~499 │ +│ MEDIUM │ never-firing-alert │ RemetricNeverFired │ │ 0 │ 0 │ ~0 │ +└──────────┴───────────────────────────────┴────────────────────┴──────────┴────────┴────────┴────────────────┘ [CRITICAL] app_requests_total · trace_id has 500 unique values Sample: trace-001f71cef44a4aca, trace-006ce2eaa75c9f65, trace-007fff8ea657221b, trace-0102b61d60431cf5, trace-0116b64ca9b86791 @@ -73,7 +73,13 @@ The output has three layers: 2. **Severity table** - at-a-glance ranking. Columns: - `SEVERITY` - `CRITICAL` / `HIGH` / `MEDIUM` / `LOW`, computed from observed series counts, uniqueness ratios, and lookback windows. - - `METRIC` / `LABEL` - the entity that triggered the finding. + - `CLASS` - finding class slug (e.g. `hot-label`, `never-firing-alert`); + each class has a dedicated documentation page at + `remetric.dev/findings/`. + - `METRIC` - the metric the finding is about. For label-only findings + this is empty (the issue is the label across many metrics). For alert + findings the alert rule name appears here. + - `LABEL` - the offending label (when applicable). - `SERIES` - total series in the metric. - `UNIQUE` - unique values seen on the offending label. - `EST. REDUCTION` - upper-bound series saved if you apply the fix. diff --git a/internal/output/terminal/findings.go b/internal/output/terminal/findings.go index ad72255..2b3728b 100644 --- a/internal/output/terminal/findings.go +++ b/internal/output/terminal/findings.go @@ -36,12 +36,20 @@ func (r *Renderer) RenderFindings(fs []findings.Finding) error { tbl.SetOutputMirror(r.w) tbl.SetStyle(table.StyleLight) tbl.Style().Format.Header = text.FormatUpper - tbl.AppendHeader(table.Row{"Severity", "Metric", "Label", "Series", "Unique", "Est. Reduction"}) + tbl.AppendHeader(table.Row{"Severity", "Class", "Metric", "Label", "Series", "Unique", "Est. Reduction"}) for _, f := range sorted { sev := r.st.severity(f.Severity).Render(f.Severity.String()) + // Alert findings have no metric, so we display the alert rule name + // in the Metric column. The Class column already disambiguates the + // finding type, so the column header stays "Metric". + metric := f.Metric + if metric == "" && f.Alert != "" { + metric = f.Alert + } tbl.AppendRow(table.Row{ sev, - truncate(f.Metric, 28), + truncate(f.Class, 30), + truncate(metric, 28), truncate(f.Evidence.Label, 24), fmtInt(f.Evidence.SeriesCount), fmtInt(int64(f.Evidence.UniqueValues)), diff --git a/internal/output/terminal/findings_test.go b/internal/output/terminal/findings_test.go index 3e1837b..aa9345e 100644 --- a/internal/output/terminal/findings_test.go +++ b/internal/output/terminal/findings_test.go @@ -39,6 +39,7 @@ func sampleFindings() []findings.Finding { { ID: "card-istio_requests_total-destination_principal", Severity: findings.SeverityCritical, Category: findings.CategoryCardinality, + Class: findings.ClassHotLabel, Title: "high cardinality in istio_requests_total due to label \"destination_principal\"", Metric: "istio_requests_total", Evidence: findings.Evidence{ @@ -53,6 +54,7 @@ func sampleFindings() []findings.Finding { { ID: "card-etcd_request_duration_seconds_bucket-grpc_method", Severity: findings.SeverityHigh, Category: findings.CategoryCardinality, + Class: findings.ClassHotLabel, Title: "high cardinality in etcd_request_duration_seconds_bucket due to label \"grpc_method\"", Metric: "etcd_request_duration_seconds_bucket", Evidence: findings.Evidence{ @@ -65,6 +67,7 @@ func sampleFindings() []findings.Finding { { ID: "card-node_filesystem_size_bytes-device", Severity: findings.SeverityMedium, Category: findings.CategoryCardinality, + Class: findings.ClassHotLabel, Title: "high cardinality in node_filesystem_size_bytes due to label \"device\"", Metric: "node_filesystem_size_bytes", Evidence: findings.Evidence{ @@ -111,6 +114,53 @@ func TestRenderFindings_FixBlockOnlyForCritHigh(t *testing.T) { } } +func TestRenderFindings_ClassColumnAndAlertFallback(t *testing.T) { + fs := []findings.Finding{ + { + ID: "card-app_requests_total-trace_id", + Severity: findings.SeverityCritical, + Category: findings.CategoryCardinality, + Class: findings.ClassHotLabel, + Metric: "app_requests_total", + Evidence: findings.Evidence{Label: "trace_id", UniqueValues: 500, SeriesCount: 500}, + Impact: findings.Impact{SeriesReduction: 499}, + }, + { + ID: "alert_hygiene/never_fired", + Severity: findings.SeverityMedium, + Category: findings.CategoryAlertHygiene, + Class: findings.ClassNeverFiringAlert, + Alert: "HighRequestLatencyP99", + // Metric and Evidence.Label intentionally empty - alert + // findings live in their own entity space. + }, + } + + var buf bytes.Buffer + r := New(&buf, WithColor(false)) + if err := r.RenderFindings(fs); err != nil { + t.Fatalf("RenderFindings: %v", err) + } + out := buf.String() + + // Class column header rendered. + if !strings.Contains(out, "CLASS") { + t.Errorf("expected CLASS column header in output:\n%s", out) + } + // Class slug appears in the cardinality row. + if !strings.Contains(out, "hot-label") { + t.Errorf("expected 'hot-label' class slug in row:\n%s", out) + } + // Class slug appears in the alert row. + if !strings.Contains(out, "never-firing-alert") { + t.Errorf("expected 'never-firing-alert' class slug in row:\n%s", out) + } + // Alert name renders in the Metric column (fallback). + if !strings.Contains(out, "HighRequestLatencyP99") { + t.Errorf("expected alert name in Metric column (fallback when Metric empty):\n%s", out) + } +} + func TestRenderFindings_LabelPatternFixBlock(t *testing.T) { f := findings.Finding{ ID: "label-user_id", diff --git a/internal/output/terminal/testdata/findings_all_severities.golden b/internal/output/terminal/testdata/findings_all_severities.golden index 05c132f..6b93035 100644 --- a/internal/output/terminal/testdata/findings_all_severities.golden +++ b/internal/output/terminal/testdata/findings_all_severities.golden @@ -1,10 +1,10 @@ -┌──────────┬──────────────────────────────┬───────────────────────┬─────────┬────────┬────────────────┐ -│ SEVERITY │ METRIC │ LABEL │ SERIES │ UNIQUE │ EST. REDUCTION │ -├──────────┼──────────────────────────────┼───────────────────────┼─────────┼────────┼────────────────┤ -│ CRITICAL │ istio_requests_total │ destination_principal │ 487,234 │ 5,234 │ ~487,141 │ -│ HIGH │ etcd_request_duration_secon… │ grpc_method │ 124,540 │ 148 │ ~123,699 │ -│ MEDIUM │ node_filesystem_size_bytes │ device │ 31,200 │ 52 │ ~30,600 │ -└──────────┴──────────────────────────────┴───────────────────────┴─────────┴────────┴────────────────┘ +┌──────────┬───────────┬──────────────────────────────┬───────────────────────┬─────────┬────────┬────────────────┐ +│ SEVERITY │ CLASS │ METRIC │ LABEL │ SERIES │ UNIQUE │ EST. REDUCTION │ +├──────────┼───────────┼──────────────────────────────┼───────────────────────┼─────────┼────────┼────────────────┤ +│ CRITICAL │ hot-label │ istio_requests_total │ destination_principal │ 487,234 │ 5,234 │ ~487,141 │ +│ HIGH │ hot-label │ etcd_request_duration_secon… │ grpc_method │ 124,540 │ 148 │ ~123,699 │ +│ MEDIUM │ hot-label │ node_filesystem_size_bytes │ device │ 31,200 │ 52 │ ~30,600 │ +└──────────┴───────────┴──────────────────────────────┴───────────────────────┴─────────┴────────┴────────────────┘ [CRITICAL] istio_requests_total · destination_principal has 5,234 unique values Sample: spiffe://orders, spiffe://payments