Skip to content

Commit 045f05d

Browse files
authored
[+] add metric list command for exporting metric definitions (#1165)
Supports listing all metrics/presets or filtering by name(s). Outputs YAML format suitable for creating custom `metrics.yaml` files. Returns errors for non-existent metric or preset names.
1 parent 8837099 commit 045f05d

4 files changed

Lines changed: 273 additions & 20 deletions

File tree

internal/cmdopts/cmdmetric.go

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@ import (
44
"context"
55
"fmt"
66
"math"
7-
"slices"
7+
8+
"gopkg.in/yaml.v3"
89
)
910

1011
type MetricCommand struct {
1112
owner *Options
1213
PrintInit MetricPrintInitCommand `command:"print-init" description:"Get and print init SQL for a given metric or preset"`
1314
PrintSQL MetricPrintSQLCommand `command:"print-sql" description:"Get and print SQL for a given metric"`
15+
List MetricListCommand `command:"list" description:"List available metrics and presets"`
1416
}
1517

1618
func NewMetricCommand(owner *Options) *MetricCommand {
1719
return &MetricCommand{
1820
owner: owner,
1921
PrintInit: MetricPrintInitCommand{owner: owner},
2022
PrintSQL: MetricPrintSQLCommand{owner: owner},
23+
List: MetricListCommand{owner: owner},
2124
}
2225
}
2326

@@ -34,21 +37,15 @@ func (cmd *MetricPrintInitCommand) Execute(args []string) error {
3437
if err != nil {
3538
return err
3639
}
37-
w := cmd.owner.OutputWriter
38-
for _, name := range args {
39-
if preset, ok := metrics.PresetDefs[name]; ok {
40-
for k := range preset.Metrics {
41-
args = append(args, k)
42-
}
43-
}
40+
metrics, err = metrics.FilterByNames(args)
41+
if err != nil {
42+
return err
4443
}
45-
slices.Sort(args)
46-
args = slices.Compact(args)
47-
for _, mname := range args {
48-
if m, ok := metrics.MetricDefs[mname]; ok && m.InitSQL != "" {
49-
44+
w := cmd.owner.OutputWriter
45+
for mname, mdef := range metrics.MetricDefs {
46+
if mdef.InitSQL > "" {
5047
fmt.Fprintln(w, "--", mname)
51-
fmt.Fprintln(w, m.InitSQL)
48+
fmt.Fprintln(w, mdef.InitSQL)
5249
}
5350
}
5451
cmd.owner.CompleteCommand(ExitCodeOK)
@@ -69,6 +66,10 @@ func (cmd *MetricPrintSQLCommand) Execute(args []string) error {
6966
if err != nil {
7067
return err
7168
}
69+
metrics, err = metrics.FilterByNames(args)
70+
if err != nil {
71+
return err
72+
}
7273
w := cmd.owner.OutputWriter
7374
if cmd.Version == 0 {
7475
cmd.Version = math.MaxInt32
@@ -84,3 +85,28 @@ func (cmd *MetricPrintSQLCommand) Execute(args []string) error {
8485
cmd.owner.CompleteCommand(ExitCodeOK)
8586
return nil
8687
}
88+
89+
type MetricListCommand struct {
90+
owner *Options
91+
}
92+
93+
func (cmd *MetricListCommand) Execute(args []string) error {
94+
err := cmd.owner.InitMetricReader(context.Background())
95+
if err != nil {
96+
return err
97+
}
98+
allMetrics, err := cmd.owner.MetricsReaderWriter.GetMetrics()
99+
if err != nil {
100+
return err
101+
}
102+
result, err := allMetrics.FilterByNames(args)
103+
if err != nil {
104+
return err
105+
}
106+
w := cmd.owner.OutputWriter
107+
108+
yamlData, _ := yaml.Marshal(result)
109+
fmt.Fprint(w, string(yamlData))
110+
cmd.owner.CompleteCommand(ExitCodeOK)
111+
return nil
112+
}

internal/cmdopts/cmdmetric_test.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ func TestMetricPrintInit_Execute(t *testing.T) {
1515
w := &strings.Builder{}
1616
os.Args = []string{0: "config_test", "metric", "print-init", "test1"}
1717
_, err = New(w)
18-
assert.Empty(t, w.String())
19-
assert.NoError(t, err, "should not error when no metrics found")
18+
assert.Error(t, err, "should error when metric not found")
19+
assert.Contains(t, err.Error(), "not found")
2020

2121
w.Reset()
2222
os.Args = []string{0: "config_test", "metric", "print-init", "cpu_load"}
@@ -45,8 +45,8 @@ func TestMetricPrintSQL_Execute(t *testing.T) {
4545
w := &strings.Builder{}
4646
os.Args = []string{0: "config_test", "metric", "print-sql", "test1"}
4747
_, err = New(w)
48-
assert.Empty(t, w.String())
49-
assert.NoError(t, err, "should not error when no metrics found")
48+
assert.Error(t, err, "should error when metric not found")
49+
assert.Contains(t, err.Error(), "not found")
5050

5151
w.Reset()
5252
os.Args = []string{0: "config_test", "metric", "print-sql", "cpu_load"}
@@ -68,3 +68,54 @@ func TestMetricPrintSQL_Execute(t *testing.T) {
6868
_, err = New(w)
6969
assert.Error(t, err, "should error when no config database found")
7070
}
71+
72+
func TestMetricList_Execute(t *testing.T) {
73+
var err error
74+
75+
// Test: List all metrics and presets (no argument)
76+
w := &strings.Builder{}
77+
os.Args = []string{0: "config_test", "metric", "list"}
78+
_, err = New(w)
79+
assert.NoError(t, err, "should not error when listing all metrics")
80+
assert.Contains(t, w.String(), "metrics:")
81+
assert.Contains(t, w.String(), "presets:")
82+
assert.Contains(t, w.String(), "cpu_load")
83+
assert.Contains(t, w.String(), "standard")
84+
85+
// Test: List specific metric
86+
w.Reset()
87+
os.Args = []string{0: "config_test", "metric", "list", "cpu_load"}
88+
_, err = New(w)
89+
assert.NoError(t, err, "should not error when listing specific metric")
90+
assert.Contains(t, w.String(), "cpu_load")
91+
assert.Contains(t, w.String(), "metrics:")
92+
// Should not contain other metrics
93+
assert.NotContains(t, w.String(), "presets:")
94+
95+
// Test: List specific preset
96+
w.Reset()
97+
os.Args = []string{0: "config_test", "metric", "list", "standard"}
98+
_, err = New(w)
99+
assert.NoError(t, err, "should not error when listing preset")
100+
assert.Contains(t, w.String(), "presets:")
101+
assert.Contains(t, w.String(), "standard")
102+
assert.Contains(t, w.String(), "metrics:")
103+
// Should contain metrics from the preset
104+
assert.Contains(t, w.String(), "cpu_load")
105+
106+
// Test: List non-existent metric/preset
107+
w.Reset()
108+
os.Args = []string{0: "config_test", "metric", "list", "nonexistent"}
109+
_, err = New(w)
110+
assert.Error(t, err, "should error when metric/preset not found")
111+
assert.Contains(t, err.Error(), "not found")
112+
113+
// Test: Error handling - invalid metrics path
114+
os.Args = []string{0: "config_test", "--metrics=foo", "metric", "list"}
115+
_, err = New(w)
116+
assert.Error(t, err, "should error when no metric definitions found")
117+
118+
os.Args = []string{0: "config_test", "--metrics=postgresql://foo@bar/fail", "metric", "list"}
119+
_, err = New(w)
120+
assert.Error(t, err, "should error when no config database found")
121+
}

internal/metrics/types.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package metrics
22

33
import (
4+
"fmt"
45
"maps"
56
"time"
67

@@ -154,9 +155,46 @@ type MeasurementEnvelope struct {
154155
}
155156

156157
type Metrics struct {
157-
MetricDefs MetricDefs `yaml:"metrics"`
158-
PresetDefs PresetDefs `yaml:"presets"`
158+
MetricDefs MetricDefs `yaml:"metrics,omitempty"`
159+
PresetDefs PresetDefs `yaml:"presets,omitempty"`
160+
}
161+
162+
// FilterByNames returns a new Metrics struct containing only the specified metrics and/or presets.
163+
// When a preset is requested, it includes both the preset definition and all its metrics.
164+
// If names is empty, returns a full copy of all metrics and presets.
165+
// Returns an error if any name is not found.
166+
func (m *Metrics) FilterByNames(names []string) (*Metrics, error) {
167+
result := &Metrics{
168+
MetricDefs: make(MetricDefs),
169+
PresetDefs: make(PresetDefs),
170+
}
171+
172+
// If no names provided, return full copy
173+
if len(names) == 0 {
174+
maps.Copy(result.MetricDefs, m.MetricDefs)
175+
maps.Copy(result.PresetDefs, m.PresetDefs)
176+
return result, nil
177+
}
178+
179+
for _, name := range names {
180+
if preset, ok := m.PresetDefs[name]; ok {
181+
result.PresetDefs[name] = preset
182+
// Include all metrics from the preset
183+
for metricName := range preset.Metrics {
184+
if metric, exists := m.MetricDefs[metricName]; exists {
185+
result.MetricDefs[metricName] = metric
186+
}
187+
}
188+
} else if metric, ok := m.MetricDefs[name]; ok {
189+
result.MetricDefs[name] = metric
190+
} else {
191+
return nil, fmt.Errorf("metric or preset '%s' not found", name)
192+
}
193+
}
194+
195+
return result, nil
159196
}
197+
160198
type Reader interface {
161199
GetMetrics() (*Metrics, error)
162200
}

internal/metrics/types_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,141 @@ func TestMeasurements(t *testing.T) {
6464
assert.NotEqual(t, m, m1, "deep copy should be different")
6565
assert.True(t, time.Now().UnixNano()-m1.GetEpoch() < int64(time.Second), "epoch should be close to now")
6666
}
67+
68+
func TestFilterByNames(t *testing.T) {
69+
// Setup test data
70+
metrics := &Metrics{
71+
MetricDefs: MetricDefs{
72+
"cpu_load": Metric{
73+
Description: "CPU load metric",
74+
InitSQL: "CREATE FUNCTION cpu_load()",
75+
},
76+
"db_size": Metric{
77+
Description: "Database size metric",
78+
},
79+
"db_stats": Metric{
80+
Description: "Database stats metric",
81+
},
82+
"replication": Metric{
83+
Description: "Replication metric",
84+
},
85+
},
86+
PresetDefs: PresetDefs{
87+
"minimal": Preset{
88+
Description: "Minimal preset",
89+
Metrics: map[string]float64{
90+
"cpu_load": 60,
91+
"db_size": 300,
92+
},
93+
},
94+
"standard": Preset{
95+
Description: "Standard preset",
96+
Metrics: map[string]float64{
97+
"cpu_load": 60,
98+
"db_size": 300,
99+
"db_stats": 60,
100+
"replication": 120,
101+
},
102+
},
103+
},
104+
}
105+
106+
tests := []struct {
107+
name string
108+
names []string
109+
wantMetrics []string
110+
wantPresets []string
111+
wantErr bool
112+
errContains string
113+
}{
114+
{
115+
name: "empty names returns all",
116+
names: []string{},
117+
wantMetrics: []string{"cpu_load", "db_size", "db_stats", "replication"},
118+
wantPresets: []string{"minimal", "standard"},
119+
wantErr: false,
120+
},
121+
{
122+
name: "single metric",
123+
names: []string{"cpu_load"},
124+
wantMetrics: []string{"cpu_load"},
125+
wantPresets: []string{},
126+
wantErr: false,
127+
},
128+
{
129+
name: "multiple metrics",
130+
names: []string{"cpu_load", "db_size"},
131+
wantMetrics: []string{"cpu_load", "db_size"},
132+
wantPresets: []string{},
133+
wantErr: false,
134+
},
135+
{
136+
name: "single preset includes all its metrics",
137+
names: []string{"minimal"},
138+
wantMetrics: []string{"cpu_load", "db_size"},
139+
wantPresets: []string{"minimal"},
140+
wantErr: false,
141+
},
142+
{
143+
name: "multiple presets",
144+
names: []string{"minimal", "standard"},
145+
wantMetrics: []string{"cpu_load", "db_size", "db_stats", "replication"},
146+
wantPresets: []string{"minimal", "standard"},
147+
wantErr: false,
148+
},
149+
{
150+
name: "mix of metrics and presets",
151+
names: []string{"minimal", "replication"},
152+
wantMetrics: []string{"cpu_load", "db_size", "replication"},
153+
wantPresets: []string{"minimal"},
154+
wantErr: false,
155+
},
156+
{
157+
name: "non-existent metric",
158+
names: []string{"nonexistent"},
159+
wantErr: true,
160+
errContains: "not found",
161+
},
162+
{
163+
name: "mix of existing and non-existing",
164+
names: []string{"cpu_load", "nonexistent"},
165+
wantErr: true,
166+
errContains: "not found",
167+
},
168+
{
169+
name: "non-existent preset",
170+
names: []string{"nonexistent_preset"},
171+
wantErr: true,
172+
errContains: "not found",
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
result, err := metrics.FilterByNames(tt.names)
179+
180+
if tt.wantErr {
181+
assert.Error(t, err)
182+
if tt.errContains != "" {
183+
assert.Contains(t, err.Error(), tt.errContains)
184+
}
185+
return
186+
}
187+
188+
assert.NoError(t, err)
189+
assert.NotNil(t, result)
190+
191+
// Check metrics
192+
assert.Equal(t, len(tt.wantMetrics), len(result.MetricDefs), "metric count mismatch")
193+
for _, metricName := range tt.wantMetrics {
194+
assert.Contains(t, result.MetricDefs, metricName, "expected metric not found: "+metricName)
195+
}
196+
197+
// Check presets
198+
assert.Equal(t, len(tt.wantPresets), len(result.PresetDefs), "preset count mismatch")
199+
for _, presetName := range tt.wantPresets {
200+
assert.Contains(t, result.PresetDefs, presetName, "expected preset not found: "+presetName)
201+
}
202+
})
203+
}
204+
}

0 commit comments

Comments
 (0)