github.com/crowdsecurity/crowdsec@v1.6.1/cmd/crowdsec-cli/metrics.go (about)

     1  package main
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"strconv"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/fatih/color"
    14  	dto "github.com/prometheus/client_model/go"
    15  	"github.com/prometheus/prom2json"
    16  	log "github.com/sirupsen/logrus"
    17  	"github.com/spf13/cobra"
    18  	"gopkg.in/yaml.v3"
    19  
    20  	"github.com/crowdsecurity/go-cs-lib/maptools"
    21  	"github.com/crowdsecurity/go-cs-lib/trace"
    22  )
    23  
    24  type (
    25  	statAcquis       map[string]map[string]int
    26  	statParser       map[string]map[string]int
    27  	statBucket       map[string]map[string]int
    28  	statWhitelist    map[string]map[string]map[string]int
    29  	statLapi         map[string]map[string]int
    30  	statLapiMachine  map[string]map[string]map[string]int
    31  	statLapiBouncer  map[string]map[string]map[string]int
    32  	statLapiDecision map[string]struct {
    33  		NonEmpty int
    34  		Empty    int
    35  	}
    36  	statDecision     map[string]map[string]map[string]int
    37  	statAppsecEngine map[string]map[string]int
    38  	statAppsecRule   map[string]map[string]map[string]int
    39  	statAlert        map[string]int
    40  	statStash        map[string]struct {
    41  		Type  string
    42  		Count int
    43  	}
    44  )
    45  
    46  var (
    47  	ErrMissingConfig   = errors.New("prometheus section missing, can't show metrics")
    48  	ErrMetricsDisabled = errors.New("prometheus is not enabled, can't show metrics")
    49  )
    50  
    51  type metricSection interface {
    52  	Table(out io.Writer, noUnit bool, showEmpty bool)
    53  	Description() (string, string)
    54  }
    55  
    56  type metricStore map[string]metricSection
    57  
    58  func NewMetricStore() metricStore {
    59  	return metricStore{
    60  		"acquisition":    statAcquis{},
    61  		"scenarios":      statBucket{},
    62  		"parsers":        statParser{},
    63  		"lapi":           statLapi{},
    64  		"lapi-machine":   statLapiMachine{},
    65  		"lapi-bouncer":   statLapiBouncer{},
    66  		"lapi-decisions": statLapiDecision{},
    67  		"decisions":      statDecision{},
    68  		"alerts":         statAlert{},
    69  		"stash":          statStash{},
    70  		"appsec-engine":  statAppsecEngine{},
    71  		"appsec-rule":    statAppsecRule{},
    72  		"whitelists":     statWhitelist{},
    73  	}
    74  }
    75  
    76  func (ms metricStore) Fetch(url string) error {
    77  	mfChan := make(chan *dto.MetricFamily, 1024)
    78  	errChan := make(chan error, 1)
    79  
    80  	// Start with the DefaultTransport for sane defaults.
    81  	transport := http.DefaultTransport.(*http.Transport).Clone()
    82  	// Conservatively disable HTTP keep-alives as this program will only
    83  	// ever need a single HTTP request.
    84  	transport.DisableKeepAlives = true
    85  	// Timeout early if the server doesn't even return the headers.
    86  	transport.ResponseHeaderTimeout = time.Minute
    87  	go func() {
    88  		defer trace.CatchPanic("crowdsec/ShowPrometheus")
    89  
    90  		err := prom2json.FetchMetricFamilies(url, mfChan, transport)
    91  		if err != nil {
    92  			errChan <- fmt.Errorf("failed to fetch metrics: %w", err)
    93  			return
    94  		}
    95  		errChan <- nil
    96  	}()
    97  
    98  	result := []*prom2json.Family{}
    99  	for mf := range mfChan {
   100  		result = append(result, prom2json.NewFamily(mf))
   101  	}
   102  
   103  	if err := <-errChan; err != nil {
   104  		return err
   105  	}
   106  
   107  	log.Debugf("Finished reading metrics output, %d entries", len(result))
   108  	/*walk*/
   109  
   110  	mAcquis := ms["acquisition"].(statAcquis)
   111  	mParser := ms["parsers"].(statParser)
   112  	mBucket := ms["scenarios"].(statBucket)
   113  	mLapi := ms["lapi"].(statLapi)
   114  	mLapiMachine := ms["lapi-machine"].(statLapiMachine)
   115  	mLapiBouncer := ms["lapi-bouncer"].(statLapiBouncer)
   116  	mLapiDecision := ms["lapi-decisions"].(statLapiDecision)
   117  	mDecision := ms["decisions"].(statDecision)
   118  	mAppsecEngine := ms["appsec-engine"].(statAppsecEngine)
   119  	mAppsecRule := ms["appsec-rule"].(statAppsecRule)
   120  	mAlert := ms["alerts"].(statAlert)
   121  	mStash := ms["stash"].(statStash)
   122  	mWhitelist := ms["whitelists"].(statWhitelist)
   123  
   124  	for idx, fam := range result {
   125  		if !strings.HasPrefix(fam.Name, "cs_") {
   126  			continue
   127  		}
   128  
   129  		log.Tracef("round %d", idx)
   130  
   131  		for _, m := range fam.Metrics {
   132  			metric, ok := m.(prom2json.Metric)
   133  			if !ok {
   134  				log.Debugf("failed to convert metric to prom2json.Metric")
   135  				continue
   136  			}
   137  
   138  			name, ok := metric.Labels["name"]
   139  			if !ok {
   140  				log.Debugf("no name in Metric %v", metric.Labels)
   141  			}
   142  
   143  			source, ok := metric.Labels["source"]
   144  			if !ok {
   145  				log.Debugf("no source in Metric %v for %s", metric.Labels, fam.Name)
   146  			} else {
   147  				if srctype, ok := metric.Labels["type"]; ok {
   148  					source = srctype + ":" + source
   149  				}
   150  			}
   151  
   152  			value := m.(prom2json.Metric).Value
   153  			machine := metric.Labels["machine"]
   154  			bouncer := metric.Labels["bouncer"]
   155  
   156  			route := metric.Labels["route"]
   157  			method := metric.Labels["method"]
   158  
   159  			reason := metric.Labels["reason"]
   160  			origin := metric.Labels["origin"]
   161  			action := metric.Labels["action"]
   162  
   163  			appsecEngine := metric.Labels["appsec_engine"]
   164  			appsecRule := metric.Labels["rule_name"]
   165  
   166  			mtype := metric.Labels["type"]
   167  
   168  			fval, err := strconv.ParseFloat(value, 32)
   169  			if err != nil {
   170  				log.Errorf("Unexpected int value %s : %s", value, err)
   171  			}
   172  
   173  			ival := int(fval)
   174  
   175  			switch fam.Name {
   176  			//
   177  			// buckets
   178  			//
   179  			case "cs_bucket_created_total":
   180  				mBucket.Process(name, "instantiation", ival)
   181  			case "cs_buckets":
   182  				mBucket.Process(name, "curr_count", ival)
   183  			case "cs_bucket_overflowed_total":
   184  				mBucket.Process(name, "overflow", ival)
   185  			case "cs_bucket_poured_total":
   186  				mBucket.Process(name, "pour", ival)
   187  				mAcquis.Process(source, "pour", ival)
   188  			case "cs_bucket_underflowed_total":
   189  				mBucket.Process(name, "underflow", ival)
   190  			//
   191  			// parsers
   192  			//
   193  			case "cs_parser_hits_total":
   194  				mAcquis.Process(source, "reads", ival)
   195  			case "cs_parser_hits_ok_total":
   196  				mAcquis.Process(source, "parsed", ival)
   197  			case "cs_parser_hits_ko_total":
   198  				mAcquis.Process(source, "unparsed", ival)
   199  			case "cs_node_hits_total":
   200  				mParser.Process(name, "hits", ival)
   201  			case "cs_node_hits_ok_total":
   202  				mParser.Process(name, "parsed", ival)
   203  			case "cs_node_hits_ko_total":
   204  				mParser.Process(name, "unparsed", ival)
   205  			//
   206  			// whitelists
   207  			//
   208  			case "cs_node_wl_hits_total":
   209  				mWhitelist.Process(name, reason, "hits", ival)
   210  			case "cs_node_wl_hits_ok_total":
   211  				mWhitelist.Process(name, reason, "whitelisted", ival)
   212  				// track as well whitelisted lines at acquis level
   213  				mAcquis.Process(source, "whitelisted", ival)
   214  			//
   215  			// lapi
   216  			//
   217  			case "cs_lapi_route_requests_total":
   218  				mLapi.Process(route, method, ival)
   219  			case "cs_lapi_machine_requests_total":
   220  				mLapiMachine.Process(machine, route, method, ival)
   221  			case "cs_lapi_bouncer_requests_total":
   222  				mLapiBouncer.Process(bouncer, route, method, ival)
   223  			case "cs_lapi_decisions_ko_total", "cs_lapi_decisions_ok_total":
   224  				mLapiDecision.Process(bouncer, fam.Name, ival)
   225  			//
   226  			// decisions
   227  			//
   228  			case "cs_active_decisions":
   229  				mDecision.Process(reason, origin, action, ival)
   230  			case "cs_alerts":
   231  				mAlert.Process(reason, ival)
   232  			//
   233  			// stash
   234  			//
   235  			case "cs_cache_size":
   236  				mStash.Process(name, mtype, ival)
   237  			//
   238  			// appsec
   239  			//
   240  			case "cs_appsec_reqs_total":
   241  				mAppsecEngine.Process(appsecEngine, "processed", ival)
   242  			case "cs_appsec_block_total":
   243  				mAppsecEngine.Process(appsecEngine, "blocked", ival)
   244  			case "cs_appsec_rule_hits":
   245  				mAppsecRule.Process(appsecEngine, appsecRule, "triggered", ival)
   246  			default:
   247  				log.Debugf("unknown: %+v", fam.Name)
   248  				continue
   249  			}
   250  		}
   251  	}
   252  
   253  	return nil
   254  }
   255  
   256  type cliMetrics struct {
   257  	cfg configGetter
   258  }
   259  
   260  func NewCLIMetrics(cfg configGetter) *cliMetrics {
   261  	return &cliMetrics{
   262  		cfg: cfg,
   263  	}
   264  }
   265  
   266  func (ms metricStore) Format(out io.Writer, sections []string, formatType string, noUnit bool) error {
   267  	// copy only the sections we want
   268  	want := map[string]metricSection{}
   269  
   270  	// if explicitly asking for sections, we want to show empty tables
   271  	showEmpty := len(sections) > 0
   272  
   273  	// if no sections are specified, we want all of them
   274  	if len(sections) == 0 {
   275  		sections = maptools.SortedKeys(ms)
   276  	}
   277  
   278  	for _, section := range sections {
   279  		want[section] = ms[section]
   280  	}
   281  
   282  	switch formatType {
   283  	case "human":
   284  		for _, section := range maptools.SortedKeys(want) {
   285  			want[section].Table(out, noUnit, showEmpty)
   286  		}
   287  	case "json":
   288  		x, err := json.MarshalIndent(want, "", " ")
   289  		if err != nil {
   290  			return fmt.Errorf("failed to marshal metrics: %w", err)
   291  		}
   292  		out.Write(x)
   293  	case "raw":
   294  		x, err := yaml.Marshal(want)
   295  		if err != nil {
   296  			return fmt.Errorf("failed to marshal metrics: %w", err)
   297  		}
   298  		out.Write(x)
   299  	default:
   300  		return fmt.Errorf("unknown format type %s", formatType)
   301  	}
   302  
   303  	return nil
   304  }
   305  
   306  func (cli *cliMetrics) show(sections []string, url string, noUnit bool) error {
   307  	cfg := cli.cfg()
   308  
   309  	if url != "" {
   310  		cfg.Cscli.PrometheusUrl = url
   311  	}
   312  
   313  	if cfg.Prometheus == nil {
   314  		return ErrMissingConfig
   315  	}
   316  
   317  	if !cfg.Prometheus.Enabled {
   318  		return ErrMetricsDisabled
   319  	}
   320  
   321  	ms := NewMetricStore()
   322  
   323  	if err := ms.Fetch(cfg.Cscli.PrometheusUrl); err != nil {
   324  		return err
   325  	}
   326  
   327  	// any section that we don't have in the store is an error
   328  	for _, section := range sections {
   329  		if _, ok := ms[section]; !ok {
   330  			return fmt.Errorf("unknown metrics type: %s", section)
   331  		}
   332  	}
   333  
   334  	if err := ms.Format(color.Output, sections, cfg.Cscli.Output, noUnit); err != nil {
   335  		return err
   336  	}
   337  
   338  	return nil
   339  }
   340  
   341  func (cli *cliMetrics) NewCommand() *cobra.Command {
   342  	var (
   343  		url    string
   344  		noUnit bool
   345  	)
   346  
   347  	cmd := &cobra.Command{
   348  		Use:   "metrics",
   349  		Short: "Display crowdsec prometheus metrics.",
   350  		Long:  `Fetch metrics from a Local API server and display them`,
   351  		Example: `# Show all Metrics, skip empty tables (same as "cecli metrics show")
   352  cscli metrics
   353  
   354  # Show only some metrics, connect to a different url
   355  cscli metrics --url http://lapi.local:6060/metrics show acquisition parsers
   356  
   357  # List available metric types
   358  cscli metrics list`,
   359  		Args:              cobra.ExactArgs(0),
   360  		DisableAutoGenTag: true,
   361  		RunE: func(_ *cobra.Command, _ []string) error {
   362  			return cli.show(nil, url, noUnit)
   363  		},
   364  	}
   365  
   366  	flags := cmd.Flags()
   367  	flags.StringVarP(&url, "url", "u", "", "Prometheus url (http://<ip>:<port>/metrics)")
   368  	flags.BoolVar(&noUnit, "no-unit", false, "Show the real number instead of formatted with units")
   369  
   370  	cmd.AddCommand(cli.newShowCmd())
   371  	cmd.AddCommand(cli.newListCmd())
   372  
   373  	return cmd
   374  }
   375  
   376  // expandAlias returns a list of sections. The input can be a list of sections or alias.
   377  func (cli *cliMetrics) expandAlias(args []string) []string {
   378  	ret := []string{}
   379  
   380  	for _, section := range args {
   381  		switch section {
   382  		case "engine":
   383  			ret = append(ret, "acquisition", "parsers", "scenarios", "stash", "whitelists")
   384  		case "lapi":
   385  			ret = append(ret, "alerts", "decisions", "lapi", "lapi-bouncer", "lapi-decisions", "lapi-machine")
   386  		case "appsec":
   387  			ret = append(ret, "appsec-engine", "appsec-rule")
   388  		default:
   389  			ret = append(ret, section)
   390  		}
   391  	}
   392  
   393  	return ret
   394  }
   395  
   396  func (cli *cliMetrics) newShowCmd() *cobra.Command {
   397  	var (
   398  		url    string
   399  		noUnit bool
   400  	)
   401  
   402  	cmd := &cobra.Command{
   403  		Use:   "show [type]...",
   404  		Short: "Display all or part of the available metrics.",
   405  		Long:  `Fetch metrics from a Local API server and display them, optionally filtering on specific types.`,
   406  		Example: `# Show all Metrics, skip empty tables
   407  cscli metrics show
   408  
   409  # Use an alias: "engine", "lapi" or "appsec" to show a group of metrics
   410  cscli metrics show engine
   411  
   412  # Show some specific metrics, show empty tables, connect to a different url
   413  cscli metrics show acquisition parsers scenarios stash --url http://lapi.local:6060/metrics
   414  
   415  # To list available metric types, use "cscli metrics list"
   416  cscli metrics list; cscli metrics list -o json
   417  
   418  # Show metrics in json format
   419  cscli metrics show acquisition parsers scenarios stash -o json`,
   420  		// Positional args are optional
   421  		DisableAutoGenTag: true,
   422  		RunE: func(_ *cobra.Command, args []string) error {
   423  			args = cli.expandAlias(args)
   424  			return cli.show(args, url, noUnit)
   425  		},
   426  	}
   427  
   428  	flags := cmd.Flags()
   429  	flags.StringVarP(&url, "url", "u", "", "Metrics url (http://<ip>:<port>/metrics)")
   430  	flags.BoolVar(&noUnit, "no-unit", false, "Show the real number instead of formatted with units")
   431  
   432  	return cmd
   433  }
   434  
   435  func (cli *cliMetrics) list() error {
   436  	type metricType struct {
   437  		Type        string `json:"type"        yaml:"type"`
   438  		Title       string `json:"title"       yaml:"title"`
   439  		Description string `json:"description" yaml:"description"`
   440  	}
   441  
   442  	var allMetrics []metricType
   443  
   444  	ms := NewMetricStore()
   445  	for _, section := range maptools.SortedKeys(ms) {
   446  		title, description := ms[section].Description()
   447  		allMetrics = append(allMetrics, metricType{
   448  			Type:        section,
   449  			Title:       title,
   450  			Description: description,
   451  		})
   452  	}
   453  
   454  	switch cli.cfg().Cscli.Output {
   455  	case "human":
   456  		t := newTable(color.Output)
   457  		t.SetRowLines(true)
   458  		t.SetHeaders("Type", "Title", "Description")
   459  
   460  		for _, metric := range allMetrics {
   461  			t.AddRow(metric.Type, metric.Title, metric.Description)
   462  		}
   463  
   464  		t.Render()
   465  	case "json":
   466  		x, err := json.MarshalIndent(allMetrics, "", " ")
   467  		if err != nil {
   468  			return fmt.Errorf("failed to marshal metric types: %w", err)
   469  		}
   470  
   471  		fmt.Println(string(x))
   472  	case "raw":
   473  		x, err := yaml.Marshal(allMetrics)
   474  		if err != nil {
   475  			return fmt.Errorf("failed to marshal metric types: %w", err)
   476  		}
   477  
   478  		fmt.Println(string(x))
   479  	}
   480  
   481  	return nil
   482  }
   483  
   484  func (cli *cliMetrics) newListCmd() *cobra.Command {
   485  	cmd := &cobra.Command{
   486  		Use:               "list",
   487  		Short:             "List available types of metrics.",
   488  		Long:              `List available types of metrics.`,
   489  		Args:              cobra.ExactArgs(0),
   490  		DisableAutoGenTag: true,
   491  		RunE: func(_ *cobra.Command, _ []string) error {
   492  			return cli.list()
   493  		},
   494  	}
   495  
   496  	return cmd
   497  }