Skip to content

Commit 9a4b495

Browse files
harp-intelclaude
andauthored
fix: cap telemetry chart Y-axis when outliers distort scale (#686)
Turbostat and sar telemetry data occasionally contains single readings that are orders of magnitude too high, compressing useful data into a tiny band at the bottom of HTML charts. Add percentile-based outlier detection that sets a hard Y-axis max when the actual maximum exceeds P99 by more than 50%, keeping charts readable while preserving outlier values in tooltips. JSON and XLSX outputs are unaffected. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4219bf5 commit 9a4b495

3 files changed

Lines changed: 124 additions & 2 deletions

File tree

cmd/telemetry/telemetry_renderers.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,38 @@ import (
1515
"strings"
1616
)
1717

18+
// computeAxisMax examines all datasets and returns a Y-axis hard max string.
19+
// If outliers are detected (actual max > P99 * 1.5), it returns a value slightly
20+
// above P99. Otherwise it returns "" (no constraint, use auto-scale).
21+
func computeAxisMax(data [][]float64) string {
22+
var all []float64
23+
for _, dataset := range data {
24+
all = append(all, dataset...)
25+
}
26+
if len(all) < 4 {
27+
return ""
28+
}
29+
sorted := make([]float64, len(all))
30+
copy(sorted, all)
31+
slices.Sort(sorted)
32+
p99Idx := int(float64(len(sorted)-1) * 0.99)
33+
p99 := sorted[p99Idx]
34+
actualMax := sorted[len(sorted)-1]
35+
if p99 > 0 && actualMax > p99*1.5 {
36+
return fmt.Sprintf("%f", p99*1.1)
37+
}
38+
return ""
39+
}
40+
1841
func telemetryTableHTMLRenderer(tableValues table.TableValues, data [][]float64, datasetNames []string, chartConfig report.ChartTemplateStruct, datasetHiddenFlags []bool) string {
1942
if len(tableValues.Fields) == 0 {
2043
slog.Error("no fields in table", slog.String("table", tableValues.Name))
2144
return ""
2245
}
46+
// Auto-detect outliers and set hard Y-axis max for auto-scaled charts
47+
if chartConfig.YaxisMax == "" && chartConfig.SuggestedMax == "0" {
48+
chartConfig.YaxisMax = computeAxisMax(data)
49+
}
2350
tsFieldIdx := 0
2451
var timestamps []string
2552
for i := range tableValues.Fields[0].Values {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (C) 2021-2025 Intel Corporation
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package telemetry
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
)
10+
11+
func TestComputeAxisMax(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
data [][]float64
15+
wantEmpty bool // true means expect "" (no constraint)
16+
wantAbove float64
17+
wantBelow float64
18+
}{
19+
{
20+
name: "normal data, no outliers",
21+
data: [][]float64{{10, 12, 11, 13, 10, 12, 11, 14, 10, 13}},
22+
wantEmpty: true,
23+
},
24+
{
25+
name: "single extreme outlier",
26+
data: [][]float64{{10, 12, 11, 13, 10, 12, 11, 14, 10, 10000}},
27+
wantEmpty: false,
28+
wantAbove: 13,
29+
wantBelow: 10000,
30+
},
31+
{
32+
name: "all identical values",
33+
data: [][]float64{{5, 5, 5, 5, 5, 5, 5, 5, 5, 5}},
34+
wantEmpty: true,
35+
},
36+
{
37+
name: "too few data points",
38+
data: [][]float64{{10, 20, 30}},
39+
wantEmpty: true,
40+
},
41+
{
42+
name: "empty data",
43+
data: [][]float64{},
44+
wantEmpty: true,
45+
},
46+
{
47+
name: "multiple datasets, one with outlier",
48+
data: [][]float64{{10, 12, 11, 13, 10}, {11, 14, 10, 13, 50000}},
49+
wantEmpty: false,
50+
wantAbove: 13,
51+
wantBelow: 50000,
52+
},
53+
{
54+
name: "gradual increase, no outlier",
55+
data: [][]float64{{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}},
56+
wantEmpty: true,
57+
},
58+
{
59+
name: "all zeros",
60+
data: [][]float64{{0, 0, 0, 0, 0}},
61+
wantEmpty: true,
62+
},
63+
}
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
got := computeAxisMax(tt.data)
67+
if tt.wantEmpty {
68+
if got != "" {
69+
t.Errorf("computeAxisMax() = %q, want empty string", got)
70+
}
71+
return
72+
}
73+
if got == "" {
74+
t.Errorf("computeAxisMax() = empty string, want a constraining value")
75+
return
76+
}
77+
// Parse and check bounds
78+
var val float64
79+
n, err := fmt.Sscanf(got, "%f", &val)
80+
if err != nil || n != 1 {
81+
t.Errorf("computeAxisMax() = %q, could not parse as float", got)
82+
return
83+
}
84+
if val <= tt.wantAbove {
85+
t.Errorf("computeAxisMax() = %f, want > %f", val, tt.wantAbove)
86+
}
87+
if val >= tt.wantBelow {
88+
t.Errorf("computeAxisMax() = %f, want < %f", val, tt.wantBelow)
89+
}
90+
})
91+
}
92+
}

internal/report/render_html.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,8 @@ new Chart(document.getElementById('{{.ID}}'), {
448448
display: true
449449
},
450450
suggestedMin: {{.SuggestedMin}},
451-
suggestedMax: {{.SuggestedMax}},
451+
suggestedMax: {{.SuggestedMax}},{{if .YaxisMax}}
452+
max: {{.YaxisMax}},{{end}}
452453
}
453454
},
454455
plugins: {
@@ -525,7 +526,8 @@ new Chart(document.getElementById('{{.ID}}'), {
525526
display: true
526527
},
527528
suggestedMin: {{.SuggestedMin}},
528-
suggestedMax: {{.SuggestedMax}},
529+
suggestedMax: {{.SuggestedMax}},{{if .YaxisMax}}
530+
max: {{.YaxisMax}},{{end}}
529531
}
530532
},
531533
plugins: {
@@ -590,6 +592,7 @@ type ChartTemplateStruct struct {
590592
AspectRatio string
591593
SuggestedMin string
592594
SuggestedMax string
595+
YaxisMax string // hard max for Y-axis; "" means no constraint
593596
}
594597

595598
// CreateFieldNameWithDescription creates HTML for a field name with optional description tooltip

0 commit comments

Comments
 (0)