Parcourir la source

refact "cscli metrics" part 3 (#2807)

mmetc il y a 1 an
Parent
commit
fdc525164a

+ 2 - 0
cmd/crowdsec-cli/main.go

@@ -146,6 +146,8 @@ It is meant to allow you to manage bans, parsers/scenarios/etc, api and generall
 		FlagsDataType: cc.White,
 		FlagsDataType: cc.White,
 		Flags:         cc.Green,
 		Flags:         cc.Green,
 		FlagsDescr:    cc.Cyan,
 		FlagsDescr:    cc.Cyan,
+		NoExtraNewlines: true,
+		NoBottomNewline: true,
 	})
 	})
 	cmd.SetOut(color.Output)
 	cmd.SetOut(color.Output)
 
 

+ 215 - 52
cmd/crowdsec-cli/metrics.go

@@ -16,6 +16,7 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v3"
 	"gopkg.in/yaml.v3"
 
 
+	"github.com/crowdsecurity/go-cs-lib/maptools"
 	"github.com/crowdsecurity/go-cs-lib/trace"
 	"github.com/crowdsecurity/go-cs-lib/trace"
 )
 )
 
 
@@ -40,18 +41,31 @@ type (
 	}
 	}
 )
 )
 
 
-type cliMetrics struct {
-	cfg configGetter
+type metricSection interface {
+	Table(io.Writer, bool, bool)
+	Description() (string, string)
 }
 }
 
 
-func NewCLIMetrics(getconfig configGetter) *cliMetrics {
-	return &cliMetrics{
-		cfg: getconfig,
+type metricStore map[string]metricSection
+
+func NewMetricStore() metricStore {
+	return metricStore{
+		"acquisition": statAcquis{},
+		"buckets": statBucket{},
+		"parsers": statParser{},
+		"lapi": statLapi{},
+		"lapi-machine": statLapiMachine{},
+		"lapi-bouncer": statLapiBouncer{},
+		"lapi-decisions": statLapiDecision{},
+		"decisions": statDecision{},
+		"alerts": statAlert{},
+		"stash": statStash{},
+		"appsec-engine": statAppsecEngine{},
+		"appsec-rule": statAppsecRule{},
 	}
 	}
 }
 }
 
 
-// FormatPrometheusMetrics is a complete rip from prom2json
-func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUnit bool) error {
+func (ms metricStore) Fetch(url string) error {
 	mfChan := make(chan *dto.MetricFamily, 1024)
 	mfChan := make(chan *dto.MetricFamily, 1024)
 	errChan := make(chan error, 1)
 	errChan := make(chan error, 1)
 
 
@@ -64,9 +78,10 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni
 	transport.ResponseHeaderTimeout = time.Minute
 	transport.ResponseHeaderTimeout = time.Minute
 	go func() {
 	go func() {
 		defer trace.CatchPanic("crowdsec/ShowPrometheus")
 		defer trace.CatchPanic("crowdsec/ShowPrometheus")
+
 		err := prom2json.FetchMetricFamilies(url, mfChan, transport)
 		err := prom2json.FetchMetricFamilies(url, mfChan, transport)
 		if err != nil {
 		if err != nil {
-			errChan <- fmt.Errorf("failed to fetch prometheus metrics: %w", err)
+			errChan <- fmt.Errorf("failed to fetch metrics: %w", err)
 			return
 			return
 		}
 		}
 		errChan <- nil
 		errChan <- nil
@@ -81,21 +96,21 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni
 		return err
 		return err
 	}
 	}
 
 
-	log.Debugf("Finished reading prometheus output, %d entries", len(result))
+	log.Debugf("Finished reading metrics output, %d entries", len(result))
 	/*walk*/
 	/*walk*/
 
 
-	mAcquis := statAcquis{}
-	mParser := statParser{}
-	mBucket := statBucket{}
-	mLapi := statLapi{}
-	mLapiMachine := statLapiMachine{}
-	mLapiBouncer := statLapiBouncer{}
-	mLapiDecision := statLapiDecision{}
-	mDecision := statDecision{}
-	mAppsecEngine := statAppsecEngine{}
-	mAppsecRule := statAppsecRule{}
-	mAlert := statAlert{}
-	mStash := statStash{}
+	mAcquis := ms["acquisition"].(statAcquis)
+	mParser := ms["parsers"].(statParser)
+	mBucket := ms["buckets"].(statBucket)
+	mLapi := ms["lapi"].(statLapi)
+	mLapiMachine := ms["lapi-machine"].(statLapiMachine)
+	mLapiBouncer := ms["lapi-bouncer"].(statLapiBouncer)
+	mLapiDecision := ms["lapi-decisions"].(statLapiDecision)
+	mDecision := ms["decisions"].(statDecision)
+	mAppsecEngine := ms["appsec-engine"].(statAppsecEngine)
+	mAppsecRule := ms["appsec-rule"].(statAppsecRule)
+	mAlert := ms["alerts"].(statAlert)
+	mStash := ms["stash"].(statStash)
 
 
 	for idx, fam := range result {
 	for idx, fam := range result {
 		if !strings.HasPrefix(fam.Name, "cs_") {
 		if !strings.HasPrefix(fam.Name, "cs_") {
@@ -281,44 +296,50 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni
 		}
 		}
 	}
 	}
 
 
-	if formatType == "human" {
-		mAcquis.table(out, noUnit)
-		mBucket.table(out, noUnit)
-		mParser.table(out, noUnit)
-		mLapi.table(out)
-		mLapiMachine.table(out)
-		mLapiBouncer.table(out)
-		mLapiDecision.table(out)
-		mDecision.table(out)
-		mAlert.table(out)
-		mStash.table(out)
-		mAppsecEngine.table(out, noUnit)
-		mAppsecRule.table(out, noUnit)
-		return nil
+	return nil
+}
+
+type cliMetrics struct {
+	cfg configGetter
+}
+
+func NewCLIMetrics(getconfig configGetter) *cliMetrics {
+	return &cliMetrics{
+		cfg: getconfig,
 	}
 	}
+}
 
 
-	stats := make(map[string]any)
+func (ms metricStore) Format(out io.Writer, sections []string, formatType string, noUnit bool) error {
+	// copy only the sections we want
+	want := map[string]metricSection{}
 
 
-	stats["acquisition"] = mAcquis
-	stats["buckets"] = mBucket
-	stats["parsers"] = mParser
-	stats["lapi"] = mLapi
-	stats["lapi_machine"] = mLapiMachine
-	stats["lapi_bouncer"] = mLapiBouncer
-	stats["lapi_decisions"] = mLapiDecision
-	stats["decisions"] = mDecision
-	stats["alerts"] = mAlert
-	stats["stash"] = mStash
+	// if explicitly asking for sections, we want to show empty tables
+	showEmpty := len(sections) > 0
+
+	// if no sections are specified, we want all of them
+	if len(sections) == 0 {
+		for section := range ms {
+			sections = append(sections, section)
+		}
+	}
+
+	for _, section := range sections {
+		want[section] = ms[section]
+	}
 
 
 	switch formatType {
 	switch formatType {
+	case "human":
+		for section := range want {
+			want[section].Table(out, noUnit, showEmpty)
+		}
 	case "json":
 	case "json":
-		x, err := json.MarshalIndent(stats, "", " ")
+		x, err := json.MarshalIndent(want, "", " ")
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("failed to unmarshal metrics : %v", err)
 			return fmt.Errorf("failed to unmarshal metrics : %v", err)
 		}
 		}
 		out.Write(x)
 		out.Write(x)
 	case "raw":
 	case "raw":
-		x, err := yaml.Marshal(stats)
+		x, err := yaml.Marshal(want)
 		if err != nil {
 		if err != nil {
 			return fmt.Errorf("failed to unmarshal metrics : %v", err)
 			return fmt.Errorf("failed to unmarshal metrics : %v", err)
 		}
 		}
@@ -330,7 +351,7 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni
 	return nil
 	return nil
 }
 }
 
 
-func (cli *cliMetrics) run(url string, noUnit bool) error {
+func (cli *cliMetrics) show(sections []string, url string, noUnit bool) error {
 	cfg := cli.cfg()
 	cfg := cli.cfg()
 
 
 	if url != "" {
 	if url != "" {
@@ -345,7 +366,20 @@ func (cli *cliMetrics) run(url string, noUnit bool) error {
 		return fmt.Errorf("prometheus is not enabled, can't show metrics")
 		return fmt.Errorf("prometheus is not enabled, can't show metrics")
 	}
 	}
 
 
-	if err := FormatPrometheusMetrics(color.Output, cfg.Cscli.PrometheusUrl, cfg.Cscli.Output, noUnit); err != nil {
+	ms := NewMetricStore()
+
+	if err := ms.Fetch(cfg.Cscli.PrometheusUrl); err != nil {
+		return err
+	}
+
+	// any section that we don't have in the store is an error
+	for _, section := range sections {
+		if _, ok := ms[section]; !ok {
+			return fmt.Errorf("unknown metrics type: %s", section)
+		}
+	}
+
+	if err := ms.Format(color.Output, sections, cfg.Cscli.Output, noUnit); err != nil {
 		return err
 		return err
 	}
 	}
 	return nil
 	return nil
@@ -360,11 +394,19 @@ func (cli *cliMetrics) NewCommand() *cobra.Command {
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
 		Use:               "metrics",
 		Use:               "metrics",
 		Short:             "Display crowdsec prometheus metrics.",
 		Short:             "Display crowdsec prometheus metrics.",
-		Long:              `Fetch metrics from the prometheus server and display them in a human-friendly way`,
+		Long:              `Fetch metrics from a Local API server and display them`,
+		Example:	   `# Show all Metrics, skip empty tables (same as "cecli metrics show")
+cscli metrics
+
+# Show only some metrics, connect to a different url
+cscli metrics --url http://lapi.local:6060/metrics show acquisition parsers
+
+# List available metric types
+cscli metrics list`,
 		Args:              cobra.ExactArgs(0),
 		Args:              cobra.ExactArgs(0),
 		DisableAutoGenTag: true,
 		DisableAutoGenTag: true,
 		RunE: func(cmd *cobra.Command, args []string) error {
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return cli.run(url, noUnit)
+			return cli.show(nil, url, noUnit)
 		},
 		},
 	}
 	}
 
 
@@ -372,5 +414,126 @@ func (cli *cliMetrics) NewCommand() *cobra.Command {
 	flags.StringVarP(&url, "url", "u", "", "Prometheus url (http://<ip>:<port>/metrics)")
 	flags.StringVarP(&url, "url", "u", "", "Prometheus url (http://<ip>:<port>/metrics)")
 	flags.BoolVar(&noUnit, "no-unit", false, "Show the real number instead of formatted with units")
 	flags.BoolVar(&noUnit, "no-unit", false, "Show the real number instead of formatted with units")
 
 
+	cmd.AddCommand(cli.newShowCmd())
+	cmd.AddCommand(cli.newListCmd())
+
+	return cmd
+}
+
+// expandAlias returns a list of sections. The input can be a list of sections or alias.
+func (cli *cliMetrics) expandSectionGroups(args []string) []string {
+	ret := []string{}
+	for _, section := range args {
+		switch section {
+		case "engine":
+			ret = append(ret, "acquisition", "parsers", "buckets", "stash")
+		case "lapi":
+			ret = append(ret, "alerts", "decisions", "lapi", "lapi-bouncer", "lapi-decisions", "lapi-machine")
+		case "appsec":
+			ret = append(ret, "appsec-engine", "appsec-rule")
+		default:
+			ret = append(ret, section)
+		}
+	}
+
+	return ret
+}
+
+func (cli *cliMetrics) newShowCmd() *cobra.Command {
+	var (
+		url string
+		noUnit bool
+	)
+
+	cmd := &cobra.Command{
+		Use:               "show [type]...",
+		Short:             "Display all or part of the available metrics.",
+		Long:              `Fetch metrics from a Local API server and display them, optionally filtering on specific types.`,
+		Example:	   `# Show all Metrics, skip empty tables
+cscli metrics show
+
+# Use an alias: "engine", "lapi" or "appsec" to show a group of metrics
+cscli metrics show engine
+
+# Show some specific metrics, show empty tables, connect to a different url
+cscli metrics show acquisition parsers buckets stash --url http://lapi.local:6060/metrics
+
+# Show metrics in json format
+cscli metrics show acquisition parsers buckets stash -o json`,
+		// Positional args are optional
+		DisableAutoGenTag: true,
+		RunE: func(_ *cobra.Command, args []string) error {
+			args = cli.expandSectionGroups(args)
+			return cli.show(args, url, noUnit)
+		},
+	}
+
+	flags := cmd.Flags()
+	flags.StringVarP(&url, "url", "u", "", "Metrics url (http://<ip>:<port>/metrics)")
+	flags.BoolVar(&noUnit, "no-unit", false, "Show the real number instead of formatted with units")
+
+	return cmd
+}
+
+func (cli *cliMetrics) list() error {
+	type metricType struct {
+		Type     string		`json:"type" yaml:"type"`
+		Title    string		`json:"title" yaml:"title"`
+		Description string	`json:"description" yaml:"description"`
+	}
+
+	var allMetrics []metricType
+
+	ms := NewMetricStore()
+	for _, section := range maptools.SortedKeys(ms) {
+		title, description := ms[section].Description()
+		allMetrics = append(allMetrics, metricType{
+			Type:        section,
+			Title:       title,
+			Description: description,
+		})
+	}
+
+	switch cli.cfg().Cscli.Output {
+	case "human":
+		t := newTable(color.Output)
+		t.SetRowLines(true)
+		t.SetHeaders("Type", "Title", "Description")
+
+		for _, metric := range allMetrics {
+			t.AddRow(metric.Type, metric.Title, metric.Description)
+		}
+
+		t.Render()
+	case "json":
+		x, err := json.MarshalIndent(allMetrics, "", " ")
+		if err != nil {
+			return fmt.Errorf("failed to unmarshal metrics: %w", err)
+		}
+		fmt.Println(string(x))
+	case "raw":
+		x, err := yaml.Marshal(allMetrics)
+		if err != nil {
+			return fmt.Errorf("failed to unmarshal metrics: %w", err)
+		}
+		fmt.Println(string(x))
+	}
+
+	return nil
+}
+
+func (cli *cliMetrics) newListCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:               "list",
+		Short:             "List available types of metrics.",
+		Long:              `List available types of metrics.`,
+		Args:              cobra.ExactArgs(0),
+		DisableAutoGenTag: true,
+		RunE: func(_ *cobra.Command, _ []string) error {
+			cli.list()
+			return nil
+		},
+	}
+
 	return cmd
 	return cmd
 }
 }

+ 114 - 56
cmd/crowdsec-cli/metrics_table.go

@@ -7,6 +7,8 @@ import (
 
 
 	"github.com/aquasecurity/table"
 	"github.com/aquasecurity/table"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
+
+	"github.com/crowdsecurity/go-cs-lib/maptools"
 )
 )
 
 
 func lapiMetricsToTable(t *table.Table, stats map[string]map[string]map[string]int) int {
 func lapiMetricsToTable(t *table.Table, stats map[string]map[string]map[string]int) int {
@@ -47,15 +49,10 @@ func metricsToTable(t *table.Table, stats map[string]map[string]int, keys []stri
 	if t == nil {
 	if t == nil {
 		return 0, fmt.Errorf("nil table")
 		return 0, fmt.Errorf("nil table")
 	}
 	}
-	// sort keys to keep consistent order when printing
-	sortedKeys := []string{}
-	for k := range stats {
-		sortedKeys = append(sortedKeys, k)
-	}
-	sort.Strings(sortedKeys)
 
 
 	numRows := 0
 	numRows := 0
-	for _, alabel := range sortedKeys {
+
+	for _, alabel := range maptools.SortedKeys(stats) {
 		astats, ok := stats[alabel]
 		astats, ok := stats[alabel]
 		if !ok {
 		if !ok {
 			continue
 			continue
@@ -81,7 +78,12 @@ func metricsToTable(t *table.Table, stats map[string]map[string]int, keys []stri
 	return numRows, nil
 	return numRows, nil
 }
 }
 
 
-func (s statBucket) table(out io.Writer, noUnit bool) {
+func (s statBucket) Description() (string, string) {
+	return "Bucket Metrics",
+		`Measure events in different scenarios. Current count is the number of buckets during metrics collection. Overflows are past event-producing buckets, while Expired are the ones that didn’t receive enough events to Overflow.`
+}
+
+func (s statBucket) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Bucket", "Current Count", "Overflows", "Instantiated", "Poured", "Expired")
 	t.SetHeaders("Bucket", "Current Count", "Overflows", "Instantiated", "Poured", "Expired")
@@ -91,13 +93,19 @@ func (s statBucket) table(out io.Writer, noUnit bool) {
 
 
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 		log.Warningf("while collecting bucket stats: %s", err)
 		log.Warningf("while collecting bucket stats: %s", err)
-	} else if numRows > 0 {
-		renderTableTitle(out, "\nBucket Metrics:")
+	} else if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statAcquis) table(out io.Writer, noUnit bool) {
+func (s statAcquis) Description() (string, string) {
+	return "Acquisition Metrics",
+		`Measures the lines read, parsed, and unparsed per datasource. Zero read lines indicate a misconfigured or inactive datasource. Zero parsed lines mean the parser(s) failed. Non-zero parsed lines are fine as crowdsec selects relevant lines.`
+}
+
+func (s statAcquis) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Source", "Lines read", "Lines parsed", "Lines unparsed", "Lines poured to bucket")
 	t.SetHeaders("Source", "Lines read", "Lines parsed", "Lines unparsed", "Lines poured to bucket")
@@ -107,13 +115,19 @@ func (s statAcquis) table(out io.Writer, noUnit bool) {
 
 
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 		log.Warningf("while collecting acquis stats: %s", err)
 		log.Warningf("while collecting acquis stats: %s", err)
-	} else if numRows > 0 {
-		renderTableTitle(out, "\nAcquisition Metrics:")
+	} else if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statAppsecEngine) table(out io.Writer, noUnit bool) {
+func (s statAppsecEngine) Description() (string, string) {
+	return "Appsec Metrics",
+		`Measures the number of parsed and blocked requests by the AppSec Component.`
+}
+
+func (s statAppsecEngine) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Appsec Engine", "Processed", "Blocked")
 	t.SetHeaders("Appsec Engine", "Processed", "Blocked")
@@ -121,13 +135,19 @@ func (s statAppsecEngine) table(out io.Writer, noUnit bool) {
 	keys := []string{"processed", "blocked"}
 	keys := []string{"processed", "blocked"}
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 		log.Warningf("while collecting appsec stats: %s", err)
 		log.Warningf("while collecting appsec stats: %s", err)
-	} else if numRows > 0 {
-		renderTableTitle(out, "\nAppsec Metrics:")
+	} else if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statAppsecRule) table(out io.Writer, noUnit bool) {
+func (s statAppsecRule) Description() (string, string) {
+	return "Appsec Rule Metrics",
+		`Provides “per AppSec Component” information about the number of matches for loaded AppSec Rules.`
+}
+
+func (s statAppsecRule) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	for appsecEngine, appsecEngineRulesStats := range s {
 	for appsecEngine, appsecEngineRulesStats := range s {
 		t := newTable(out)
 		t := newTable(out)
 		t.SetRowLines(false)
 		t.SetRowLines(false)
@@ -136,7 +156,7 @@ func (s statAppsecRule) table(out io.Writer, noUnit bool) {
 		keys := []string{"triggered"}
 		keys := []string{"triggered"}
 		if numRows, err := metricsToTable(t, appsecEngineRulesStats, keys, noUnit); err != nil {
 		if numRows, err := metricsToTable(t, appsecEngineRulesStats, keys, noUnit); err != nil {
 			log.Warningf("while collecting appsec rules stats: %s", err)
 			log.Warningf("while collecting appsec rules stats: %s", err)
-		} else if numRows > 0 {
+		} else if numRows > 0 || showEmpty{
 			renderTableTitle(out, fmt.Sprintf("\nAppsec '%s' Rules Metrics:", appsecEngine))
 			renderTableTitle(out, fmt.Sprintf("\nAppsec '%s' Rules Metrics:", appsecEngine))
 			t.Render()
 			t.Render()
 		}
 		}
@@ -144,7 +164,12 @@ func (s statAppsecRule) table(out io.Writer, noUnit bool) {
 
 
 }
 }
 
 
-func (s statParser) table(out io.Writer, noUnit bool) {
+func (s statParser) Description() (string, string) {
+	return "Parser Metrics",
+		`Tracks the number of events processed by each parser and indicates success of failure. Zero parsed lines means the parer(s) failed. Non-zero unparsed lines are fine as crowdsec select relevant lines.`
+}
+
+func (s statParser) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Parsers", "Hits", "Parsed", "Unparsed")
 	t.SetHeaders("Parsers", "Hits", "Parsed", "Unparsed")
@@ -154,27 +179,28 @@ func (s statParser) table(out io.Writer, noUnit bool) {
 
 
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 	if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil {
 		log.Warningf("while collecting parsers stats: %s", err)
 		log.Warningf("while collecting parsers stats: %s", err)
-	} else if numRows > 0 {
-		renderTableTitle(out, "\nParser Metrics:")
+	} else if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statStash) table(out io.Writer) {
+func (s statStash) Description() (string, string) {
+	return "Parser Stash Metrics",
+		`Tracks the status of stashes that might be created by various parsers and scenarios.`
+}
+
+func (s statStash) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Name", "Type", "Items")
 	t.SetHeaders("Name", "Type", "Items")
 	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
 	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
 
 
 	// unfortunately, we can't reuse metricsToTable as the structure is too different :/
 	// unfortunately, we can't reuse metricsToTable as the structure is too different :/
-	sortedKeys := []string{}
-	for k := range s {
-		sortedKeys = append(sortedKeys, k)
-	}
-	sort.Strings(sortedKeys)
-
 	numRows := 0
 	numRows := 0
-	for _, alabel := range sortedKeys {
+
+	for _, alabel := range maptools.SortedKeys(s) {
 		astats := s[alabel]
 		astats := s[alabel]
 
 
 		row := []string{
 		row := []string{
@@ -185,27 +211,28 @@ func (s statStash) table(out io.Writer) {
 		t.AddRow(row...)
 		t.AddRow(row...)
 		numRows++
 		numRows++
 	}
 	}
-	if numRows > 0 {
-		renderTableTitle(out, "\nParser Stash Metrics:")
+	if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statLapi) table(out io.Writer) {
+func (s statLapi) Description() (string, string) {
+	return "Local API Metrics",
+		`Monitors the requests made to local API routes.`
+}
+
+func (s statLapi) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Route", "Method", "Hits")
 	t.SetHeaders("Route", "Method", "Hits")
 	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
 	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
 
 
 	// unfortunately, we can't reuse metricsToTable as the structure is too different :/
 	// unfortunately, we can't reuse metricsToTable as the structure is too different :/
-	sortedKeys := []string{}
-	for k := range s {
-		sortedKeys = append(sortedKeys, k)
-	}
-	sort.Strings(sortedKeys)
-
 	numRows := 0
 	numRows := 0
-	for _, alabel := range sortedKeys {
+
+	for _, alabel := range maptools.SortedKeys(s) {
 		astats := s[alabel]
 		astats := s[alabel]
 
 
 		subKeys := []string{}
 		subKeys := []string{}
@@ -225,13 +252,19 @@ func (s statLapi) table(out io.Writer) {
 		}
 		}
 	}
 	}
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Metrics:")
+	if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statLapiMachine) table(out io.Writer) {
+func (s statLapiMachine) Description() (string, string) {
+	return "Local API Machines Metrics",
+		`Tracks the number of calls to the local API from each registered machine.`
+}
+
+func (s statLapiMachine) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Machine", "Route", "Method", "Hits")
 	t.SetHeaders("Machine", "Route", "Method", "Hits")
@@ -239,13 +272,19 @@ func (s statLapiMachine) table(out io.Writer) {
 
 
 	numRows := lapiMetricsToTable(t, s)
 	numRows := lapiMetricsToTable(t, s)
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Machines Metrics:")
+	if numRows > 0 || showEmpty{
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statLapiBouncer) table(out io.Writer) {
+func (s statLapiBouncer) Description() (string, string) {
+	return "Local API Bouncers Metrics",
+		`Tracks total hits to remediation component related API routes.`
+}
+
+func (s statLapiBouncer) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Bouncer", "Route", "Method", "Hits")
 	t.SetHeaders("Bouncer", "Route", "Method", "Hits")
@@ -253,13 +292,19 @@ func (s statLapiBouncer) table(out io.Writer) {
 
 
 	numRows := lapiMetricsToTable(t, s)
 	numRows := lapiMetricsToTable(t, s)
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Bouncers Metrics:")
+	if numRows > 0 || showEmpty {
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statLapiDecision) table(out io.Writer) {
+func (s statLapiDecision) Description() (string, string) {
+	return "Local API Bouncers Decisions",
+		`Tracks the number of empty/non-empty answers from LAPI to bouncers that are working in "live" mode.`
+}
+
+func (s statLapiDecision) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Bouncer", "Empty answers", "Non-empty answers")
 	t.SetHeaders("Bouncer", "Empty answers", "Non-empty answers")
@@ -275,13 +320,19 @@ func (s statLapiDecision) table(out io.Writer) {
 		numRows++
 		numRows++
 	}
 	}
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Bouncers Decisions:")
+	if numRows > 0 || showEmpty{
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statDecision) table(out io.Writer) {
+func (s statDecision) Description() (string, string) {
+	return "Local API Decisions",
+		`Provides information about all currently active decisions. Includes both local (crowdsec) and global decisions (CAPI), and lists subscriptions (lists).`
+}
+
+func (s statDecision) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Reason", "Origin", "Action", "Count")
 	t.SetHeaders("Reason", "Origin", "Action", "Count")
@@ -302,13 +353,19 @@ func (s statDecision) table(out io.Writer) {
 		}
 		}
 	}
 	}
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Decisions:")
+	if numRows > 0 || showEmpty{
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }
 
 
-func (s statAlert) table(out io.Writer) {
+func (s statAlert) Description() (string, string) {
+	return "Local API Alerts",
+		`Tracks the total number of past and present alerts for the installed scenarios.`
+}
+
+func (s statAlert) Table(out io.Writer, noUnit bool, showEmpty bool) {
 	t := newTable(out)
 	t := newTable(out)
 	t.SetRowLines(false)
 	t.SetRowLines(false)
 	t.SetHeaders("Reason", "Count")
 	t.SetHeaders("Reason", "Count")
@@ -323,8 +380,9 @@ func (s statAlert) table(out io.Writer) {
 		numRows++
 		numRows++
 	}
 	}
 
 
-	if numRows > 0 {
-		renderTableTitle(out, "\nLocal API Alerts:")
+	if numRows > 0 || showEmpty{
+		title, _ := s.Description()
+		renderTableTitle(out, "\n" + title + ":")
 		t.Render()
 		t.Render()
 	}
 	}
 }
 }

+ 8 - 3
cmd/crowdsec-cli/support.go

@@ -66,10 +66,15 @@ func collectMetrics() ([]byte, []byte, error) {
 	}
 	}
 
 
 	humanMetrics := bytes.NewBuffer(nil)
 	humanMetrics := bytes.NewBuffer(nil)
-	err := FormatPrometheusMetrics(humanMetrics, csConfig.Cscli.PrometheusUrl, "human", false)
 
 
-	if err != nil {
-		return nil, nil, fmt.Errorf("could not fetch promtheus metrics: %s", err)
+	ms := NewMetricStore()
+
+	if err := ms.Fetch(csConfig.Cscli.PrometheusUrl); err != nil {
+		return nil, nil, fmt.Errorf("could not fetch prometheus metrics: %s", err)
+	}
+
+	if err := ms.Format(humanMetrics, nil, "human", false); err != nil {
+		return nil, nil, err
 	}
 	}
 
 
 	req, err := http.NewRequest(http.MethodGet, csConfig.Cscli.PrometheusUrl, nil)
 	req, err := http.NewRequest(http.MethodGet, csConfig.Cscli.PrometheusUrl, nil)

+ 0 - 9
test/bats/01_cscli.bats

@@ -273,15 +273,6 @@ teardown() {
     assert_output 'failed to authenticate to Local API (LAPI): API error: incorrect Username or Password'
     assert_output 'failed to authenticate to Local API (LAPI): API error: incorrect Username or Password'
 }
 }
 
 
-@test "cscli metrics" {
-    rune -0 ./instance-crowdsec start
-    rune -0 cscli lapi status
-    rune -0 cscli metrics
-    assert_output --partial "Route"
-    assert_output --partial '/v1/watchers/login'
-    assert_output --partial "Local API Metrics:"
-}
-
 @test "'cscli completion' with or without configuration file" {
 @test "'cscli completion' with or without configuration file" {
     rune -0 cscli completion bash
     rune -0 cscli completion bash
     assert_output --partial "# bash completion for cscli"
     assert_output --partial "# bash completion for cscli"

+ 55 - 1
test/bats/08_metrics.bats

@@ -25,7 +25,7 @@ teardown() {
 @test "cscli metrics (crowdsec not running)" {
 @test "cscli metrics (crowdsec not running)" {
     rune -1 cscli metrics
     rune -1 cscli metrics
     # crowdsec is down
     # crowdsec is down
-    assert_stderr --partial 'failed to fetch prometheus metrics: executing GET request for URL \"http://127.0.0.1:6060/metrics\" failed: Get \"http://127.0.0.1:6060/metrics\": dial tcp 127.0.0.1:6060: connect: connection refused'
+    assert_stderr --partial 'failed to fetch metrics: executing GET request for URL \"http://127.0.0.1:6060/metrics\" failed: Get \"http://127.0.0.1:6060/metrics\": dial tcp 127.0.0.1:6060: connect: connection refused'
 }
 }
 
 
 @test "cscli metrics (bad configuration)" {
 @test "cscli metrics (bad configuration)" {
@@ -59,3 +59,57 @@ teardown() {
     rune -1 cscli metrics
     rune -1 cscli metrics
     assert_stderr --partial "prometheus is not enabled, can't show metrics"
     assert_stderr --partial "prometheus is not enabled, can't show metrics"
 }
 }
+
+@test "cscli metrics" {
+    rune -0 ./instance-crowdsec start
+    rune -0 cscli lapi status
+    rune -0 cscli metrics
+    assert_output --partial "Route"
+    assert_output --partial '/v1/watchers/login'
+    assert_output --partial "Local API Metrics:"
+
+    rune -0 cscli metrics -o json
+    rune -0 jq 'keys' <(output)
+    assert_output --partial '"alerts",'
+    assert_output --partial '"parsers",'
+
+    rune -0 cscli metrics -o raw
+    assert_output --partial 'alerts: {}'
+    assert_output --partial 'parsers: {}'
+}
+
+@test "cscli metrics list" {
+    rune -0 cscli metrics list
+    assert_output --regexp "Type.*Title.*Description"
+
+    rune -0 cscli metrics list -o json
+    rune -0 jq -c '.[] | [.type,.title]' <(output)
+    assert_line '["acquisition","Acquisition Metrics"]'
+
+    rune -0 cscli metrics list -o raw
+    assert_line "- type: acquisition"
+    assert_line "  title: Acquisition Metrics"
+}
+
+@test "cscli metrics show" {
+    rune -0 ./instance-crowdsec start
+    rune -0 cscli lapi status
+
+    assert_equal "$(cscli metrics)" "$(cscli metrics show)"
+
+    rune -1 cscli metrics show foobar
+    assert_stderr --partial "unknown metrics type: foobar"
+
+    rune -0 cscli metrics show lapi
+    assert_output --partial "Local API Metrics:"
+    assert_output --regexp "Route.*Method.*Hits"
+    assert_output --regexp "/v1/watchers/login.*POST"
+
+    rune -0 cscli metrics show lapi -o json
+    rune -0 jq -c '.lapi."/v1/watchers/login" | keys' <(output)
+    assert_json '["POST"]'
+
+    rune -0 cscli metrics show lapi -o raw
+    assert_line 'lapi:'
+    assert_line '    /v1/watchers/login:'
+}