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 }