diff --git a/cmd/crowdsec-cli/main.go b/cmd/crowdsec-cli/main.go index db3a164af90..3b20cf112c0 100644 --- a/cmd/crowdsec-cli/main.go +++ b/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, Flags: cc.Green, FlagsDescr: cc.Cyan, + NoExtraNewlines: true, + NoBottomNewline: true, }) cmd.SetOut(color.Output) diff --git a/cmd/crowdsec-cli/metrics.go b/cmd/crowdsec-cli/metrics.go index ad255e847db..c883c809291 100644 --- a/cmd/crowdsec-cli/metrics.go +++ b/cmd/crowdsec-cli/metrics.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/crowdsecurity/go-cs-lib/maptools" "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) errChan := make(chan error, 1) @@ -64,9 +78,10 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni transport.ResponseHeaderTimeout = time.Minute go func() { defer trace.CatchPanic("crowdsec/ShowPrometheus") + err := prom2json.FetchMetricFamilies(url, mfChan, transport) if err != nil { - errChan <- fmt.Errorf("failed to fetch prometheus metrics: %w", err) + errChan <- fmt.Errorf("failed to fetch metrics: %w", err) return } errChan <- nil @@ -81,21 +96,21 @@ func FormatPrometheusMetrics(out io.Writer, url string, formatType string, noUni return err } - log.Debugf("Finished reading prometheus output, %d entries", len(result)) + log.Debugf("Finished reading metrics output, %d entries", len(result)) /*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 { 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 { + case "human": + for section := range want { + want[section].Table(out, noUnit, showEmpty) + } case "json": - x, err := json.MarshalIndent(stats, "", " ") + x, err := json.MarshalIndent(want, "", " ") if err != nil { return fmt.Errorf("failed to unmarshal metrics : %v", err) } out.Write(x) case "raw": - x, err := yaml.Marshal(stats) + x, err := yaml.Marshal(want) if err != nil { 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 } -func (cli *cliMetrics) run(url string, noUnit bool) error { +func (cli *cliMetrics) show(sections []string, url string, noUnit bool) error { cfg := cli.cfg() 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") } - 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 nil @@ -360,11 +394,19 @@ func (cli *cliMetrics) NewCommand() *cobra.Command { cmd := &cobra.Command{ Use: "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), DisableAutoGenTag: true, 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://:/metrics)") 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://:/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 } diff --git a/cmd/crowdsec-cli/metrics_table.go b/cmd/crowdsec-cli/metrics_table.go index 835277aa4ee..72f53f94c49 100644 --- a/cmd/crowdsec-cli/metrics_table.go +++ b/cmd/crowdsec-cli/metrics_table.go @@ -7,6 +7,8 @@ import ( "github.com/aquasecurity/table" 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 { @@ -47,15 +49,10 @@ func metricsToTable(t *table.Table, stats map[string]map[string]int, keys []stri if t == nil { 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 - for _, alabel := range sortedKeys { + + for _, alabel := range maptools.SortedKeys(stats) { astats, ok := stats[alabel] if !ok { continue @@ -81,7 +78,12 @@ func metricsToTable(t *table.Table, stats map[string]map[string]int, keys []stri 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.SetRowLines(false) 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 { 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() } } -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.SetRowLines(false) 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 { 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() } } -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.SetRowLines(false) t.SetHeaders("Appsec Engine", "Processed", "Blocked") @@ -121,13 +135,19 @@ func (s statAppsecEngine) table(out io.Writer, noUnit bool) { keys := []string{"processed", "blocked"} if numRows, err := metricsToTable(t, s, keys, noUnit); err != nil { 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() } } -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 { t := newTable(out) t.SetRowLines(false) @@ -136,7 +156,7 @@ func (s statAppsecRule) table(out io.Writer, noUnit bool) { keys := []string{"triggered"} if numRows, err := metricsToTable(t, appsecEngineRulesStats, keys, noUnit); err != nil { 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)) 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.SetRowLines(false) 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 { 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() } } -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.SetRowLines(false) t.SetHeaders("Name", "Type", "Items") t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft) // 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 - for _, alabel := range sortedKeys { + + for _, alabel := range maptools.SortedKeys(s) { astats := s[alabel] row := []string{ @@ -185,27 +211,28 @@ func (s statStash) table(out io.Writer) { t.AddRow(row...) numRows++ } - if numRows > 0 { - renderTableTitle(out, "\nParser Stash Metrics:") + if numRows > 0 || showEmpty { + title, _ := s.Description() + renderTableTitle(out, "\n" + title + ":") 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.SetRowLines(false) t.SetHeaders("Route", "Method", "Hits") t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft) // 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 - for _, alabel := range sortedKeys { + + for _, alabel := range maptools.SortedKeys(s) { astats := s[alabel] 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() } } -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.SetRowLines(false) t.SetHeaders("Machine", "Route", "Method", "Hits") @@ -239,13 +272,19 @@ func (s statLapiMachine) table(out io.Writer) { 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() } } -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.SetRowLines(false) t.SetHeaders("Bouncer", "Route", "Method", "Hits") @@ -253,13 +292,19 @@ func (s statLapiBouncer) table(out io.Writer) { 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() } } -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.SetRowLines(false) t.SetHeaders("Bouncer", "Empty answers", "Non-empty answers") @@ -275,13 +320,19 @@ func (s statLapiDecision) table(out io.Writer) { numRows++ } - if numRows > 0 { - renderTableTitle(out, "\nLocal API Bouncers Decisions:") + if numRows > 0 || showEmpty{ + title, _ := s.Description() + renderTableTitle(out, "\n" + title + ":") 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.SetRowLines(false) 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() } } -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.SetRowLines(false) t.SetHeaders("Reason", "Count") @@ -323,8 +380,9 @@ func (s statAlert) table(out io.Writer) { numRows++ } - if numRows > 0 { - renderTableTitle(out, "\nLocal API Alerts:") + if numRows > 0 || showEmpty{ + title, _ := s.Description() + renderTableTitle(out, "\n" + title + ":") t.Render() } } diff --git a/cmd/crowdsec-cli/support.go b/cmd/crowdsec-cli/support.go index e0a2fa9db90..661950fa8f6 100644 --- a/cmd/crowdsec-cli/support.go +++ b/cmd/crowdsec-cli/support.go @@ -66,10 +66,15 @@ func collectMetrics() ([]byte, []byte, error) { } 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) diff --git a/test/bats/01_cscli.bats b/test/bats/01_cscli.bats index 3a5b4aad04c..60a65b98d58 100644 --- a/test/bats/01_cscli.bats +++ b/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' } -@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" { rune -0 cscli completion bash assert_output --partial "# bash completion for cscli" diff --git a/test/bats/08_metrics.bats b/test/bats/08_metrics.bats index 0275d7fd4a0..8bf30812cff 100644 --- a/test/bats/08_metrics.bats +++ b/test/bats/08_metrics.bats @@ -25,7 +25,7 @@ teardown() { @test "cscli metrics (crowdsec not running)" { rune -1 cscli metrics # 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)" { @@ -59,3 +59,57 @@ teardown() { rune -1 cscli 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:' +}