From 97adef458c51368f1e7d106107ee8b7162ed8cca Mon Sep 17 00:00:00 2001 From: conorbroderick Date: Wed, 2 Aug 2017 12:35:36 +0100 Subject: [PATCH] Extract numeric time series from string values --- collector.go | 66 +++++++--- collector_test.go | 216 +++++++++++++++++++++++++++---- config/config.go | 68 ++++++++-- config_test.go | 14 ++ generator/FORMAT.md | 7 +- generator/README.md | 14 +- generator/config.go | 25 +++- generator/tree.go | 9 ++ generator/tree_test.go | 39 +++++- testdata/snmp-with-overrides.yml | 10 ++ 10 files changed, 405 insertions(+), 63 deletions(-) create mode 100644 testdata/snmp-with-overrides.yml diff --git a/collector.go b/collector.go index 16ef6b7c..fe6ac11b 100644 --- a/collector.go +++ b/collector.go @@ -154,7 +154,10 @@ PduLoop: } if head.metric != nil { // Found a match. - ch <- pduToSample(oidList[i+1:], &pdu, head.metric, oidToPdu) + samples := pduToSamples(oidList[i+1:], &pdu, head.metric, oidToPdu) + for _, sample := range samples { + ch <- sample + } break } } @@ -174,13 +177,19 @@ func getPduValue(pdu *gosnmp.SnmpPDU) float64 { } } -func pduToSample(indexOids []int, pdu *gosnmp.SnmpPDU, metric *config.Metric, oidToPdu map[string]gosnmp.SnmpPDU) prometheus.Metric { +func pduToSamples(indexOids []int, pdu *gosnmp.SnmpPDU, metric *config.Metric, oidToPdu map[string]gosnmp.SnmpPDU) []prometheus.Metric { // The part of the OID that is the indexes. labels := indexesToLabels(indexOids, metric, oidToPdu) value := getPduValue(pdu) t := prometheus.UntypedValue - stringType := false + + labelnames := make([]string, 0, len(labels)+1) + labelvalues := make([]string, 0, len(labels)+1) + for k, v := range labels { + labelnames = append(labelnames, k) + labelvalues = append(labelvalues, v) + } switch metric.Type { case "counter": @@ -191,25 +200,48 @@ func pduToSample(indexOids []int, pdu *gosnmp.SnmpPDU, metric *config.Metric, oi // It's some form of string. t = prometheus.GaugeValue value = 1.0 - stringType = true - } - - labelnames := make([]string, 0, len(labels)+1) - labelvalues := make([]string, 0, len(labels)+1) - for k, v := range labels { - labelnames = append(labelnames, k) - labelvalues = append(labelvalues, v) - } - // For strings we put the value as a label with the same name as the metric. - // If the name is already an index, we do not need to set it again. - if stringType { + if len(metric.RegexpExtracts) > 0 { + v, ok := pdu.Value.(string) + if !ok { + log.Errorf("Invalid PDU value type: got %T, want string for metric: %v", pdu.Value, metric.Name) + return nil + } + return applyRegexExtracts(metric, v, labelnames, labelvalues) + } + // For strings we put the value as a label with the same name as the metric. + // If the name is already an index, we do not need to set it again. if _, ok := labels[metric.Name]; !ok { labelnames = append(labelnames, metric.Name) labelvalues = append(labelvalues, pduValueAsString(pdu, metric.Type)) } } - return prometheus.MustNewConstMetric(prometheus.NewDesc(metric.Name, metric.Help, labelnames, nil), - t, value, labelvalues...) + + return []prometheus.Metric{prometheus.MustNewConstMetric(prometheus.NewDesc(metric.Name, metric.Help, labelnames, nil), + t, value, labelvalues...)} +} + +func applyRegexExtracts(metric *config.Metric, pduValue string, labelnames, labelvalues []string) []prometheus.Metric { + results := []prometheus.Metric{} + for name, strMetricSlice := range metric.RegexpExtracts { + for _, strMetric := range strMetricSlice { + indexes := strMetric.Regex.FindStringSubmatchIndex(pduValue) + if indexes == nil { + log.Debugf("No match found for regexp: %v against value: %v for metric %v", strMetric.Regex.String(), pduValue, metric.Name) + continue + } + res := strMetric.Regex.ExpandString([]byte{}, strMetric.Value, pduValue, indexes) + v, err := strconv.ParseFloat(string(res), 64) + if err != nil { + log.Debugf("Error parsing float64 from value: %v for metric: %v", res, metric.Name) + continue + } + newMetric := prometheus.MustNewConstMetric(prometheus.NewDesc(metric.Name+name, metric.Help+" (regex extracted)", labelnames, nil), + prometheus.GaugeValue, v, labelvalues...) + results = append(results, newMetric) + break + } + } + return results } // Right pad oid with zeros, and split at the given point. diff --git a/collector_test.go b/collector_test.go index 7238ca5b..eabbba2f 100644 --- a/collector_test.go +++ b/collector_test.go @@ -2,23 +2,183 @@ package main import ( "reflect" + "regexp" "testing" + "github.com/prometheus/client_model/go" "github.com/soniah/gosnmp" - "github.com/prometheus/client_model/go" "github.com/prometheus/snmp_exporter/config" ) func TestPduToSample(t *testing.T) { + cases := []struct { - pdu *gosnmp.SnmpPDU - indexOids []int - metric *config.Metric - oidToPdu map[string]gosnmp.SnmpPDU - expectedMetric string - expectedMetricDesc string + pdu *gosnmp.SnmpPDU + indexOids []int + metric *config.Metric + oidToPdu map[string]gosnmp.SnmpPDU + expectedMetrics map[string]string }{ + { + pdu: &gosnmp.SnmpPDU{ + Name: "1.1.1.1.1", + Value: "SomeStringValue", + }, + indexOids: []int{}, + metric: &config.Metric{ + Name: "TestMetricName", + Oid: "1.1.1.1.1", + Help: "HelpText", + RegexpExtracts: map[string][]config.RegexpExtract{ + "Extension": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile(".*"), + }, + Value: "5", + }, + }, + }, + }, + oidToPdu: make(map[string]gosnmp.SnmpPDU), + expectedMetrics: map[string]string{ + `gauge: `: `Desc{fqName: "TestMetricNameExtension", help: "HelpText (regex extracted)", constLabels: {}, variableLabels: []}`, + }, + }, + { + pdu: &gosnmp.SnmpPDU{ + Name: "1.1.1.1.1", + Value: "SomeStringValue", + }, + indexOids: []int{}, + metric: &config.Metric{ + Name: "TestMetricName", + Oid: "1.1.1.1.1", + Help: "HelpText", + RegexpExtracts: map[string][]config.RegexpExtract{ + "Extension": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile(".*"), + }, + Value: "", + }, + }, + }, + }, + expectedMetrics: map[string]string{}, + }, + { + pdu: &gosnmp.SnmpPDU{ + Name: "1.1.1.1.1", + Value: "SomeStringValue", + }, + indexOids: []int{}, + metric: &config.Metric{ + Name: "TestMetricName", + Oid: "1.1.1.1.1", + Help: "HelpText", + RegexpExtracts: map[string][]config.RegexpExtract{ + "Extension": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile("(will_not_match)"), + }, + Value: "", + }, + }, + }, + }, + expectedMetrics: map[string]string{}, + }, + { + pdu: &gosnmp.SnmpPDU{ + Name: "1.1.1.1.1", + Value: 2, + }, + indexOids: []int{}, + metric: &config.Metric{ + Name: "TestMetricName", + Oid: "1.1.1.1.1", + Help: "HelpText", + RegexpExtracts: map[string][]config.RegexpExtract{ + "Status": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile(".*"), + }, + Value: "5", + }, + }, + }, + }, + expectedMetrics: map[string]string{}, + }, + { + pdu: &gosnmp.SnmpPDU{ + Name: "1.1.1.1.1", + Value: "Test value 4.42 123 999", + }, + indexOids: []int{}, + metric: &config.Metric{ + Name: "TestMetricName", + Oid: "1.1.1.1.1", + Help: "HelpText", + RegexpExtracts: map[string][]config.RegexpExtract{ + "Blank": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile("XXXX"), + }, + Value: "4", + }, + }, + "Extension": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile(".*"), + }, + Value: "5", + }, + }, + "MultipleRegexes": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile("XXXX"), + }, + Value: "123", + }, + { + Regex: config.Regexp{ + regexp.MustCompile("123"), + }, + Value: "999", + }, + { + Regex: config.Regexp{ + regexp.MustCompile(".*"), + }, + Value: "777", + }, + }, + "Template": []config.RegexpExtract{ + { + Regex: config.Regexp{ + regexp.MustCompile("([0-9].[0-9]+)"), + }, + Value: "$1", + }, + }, + }, + }, + oidToPdu: make(map[string]gosnmp.SnmpPDU), + expectedMetrics: map[string]string{ + `gauge: `: `Desc{fqName: "TestMetricNameExtension", help: "HelpText (regex extracted)", constLabels: {}, variableLabels: []}`, + `gauge: `: `Desc{fqName: "TestMetricNameMultipleRegexes", help: "HelpText (regex extracted)", constLabels: {}, variableLabels: []}`, + `gauge: `: `Desc{fqName: "TestMetricNameTemplate", help: "HelpText (regex extracted)", constLabels: {}, variableLabels: []}`, + }, + }, { pdu: &gosnmp.SnmpPDU{ Name: "1.1.1.1.1", @@ -32,9 +192,8 @@ func TestPduToSample(t *testing.T) { Type: "counter", Help: "Help string", }, - oidToPdu: make(map[string]gosnmp.SnmpPDU), - expectedMetric: "counter: ", - expectedMetricDesc: `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: []}`, + oidToPdu: make(map[string]gosnmp.SnmpPDU), + expectedMetrics: map[string]string{"counter: ": `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: []}`}, }, { pdu: &gosnmp.SnmpPDU{ @@ -49,9 +208,8 @@ func TestPduToSample(t *testing.T) { Type: "gauge", Help: "Help string", }, - oidToPdu: make(map[string]gosnmp.SnmpPDU), - expectedMetric: "gauge: ", - expectedMetricDesc: `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: []}`, + oidToPdu: make(map[string]gosnmp.SnmpPDU), + expectedMetrics: map[string]string{"gauge: ": `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: []}`}, }, { pdu: &gosnmp.SnmpPDU{ @@ -65,24 +223,28 @@ func TestPduToSample(t *testing.T) { Oid: "1.1.1.1.1", Help: "Help string", }, - oidToPdu: make(map[string]gosnmp.SnmpPDU), - expectedMetric: `label: gauge: `, - expectedMetricDesc: `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: [test_metric]}`, + oidToPdu: make(map[string]gosnmp.SnmpPDU), + expectedMetrics: map[string]string{`label: gauge: `: `Desc{fqName: "test_metric", help: "Help string", constLabels: {}, variableLabels: [test_metric]}`}, }, } - for _, c := range cases { - m := pduToSample(c.indexOids, c.pdu, c.metric, c.oidToPdu) - metric := &io_prometheus_client.Metric{} - err := m.Write(metric) - if err != nil { - t.Fatalf("Error writing metric: %v", err) - } - if metric.String() != c.expectedMetric { - t.Fatalf("Unexpected metric: got %v, want %v", metric.String(), c.expectedMetric) + for i, c := range cases { + metrics := pduToSamples(c.indexOids, c.pdu, c.metric, c.oidToPdu) + if len(metrics) != len(c.expectedMetrics) { + t.Fatalf("Unexpected number of metrics returned for case %v: want %v, got %v", i, len(c.expectedMetrics), len(metrics)) } - if m.Desc().String() != c.expectedMetricDesc { - t.Fatalf("Unexpected metric description: got %v, want %v", m.Desc().String(), c.expectedMetricDesc) + metric := &io_prometheus_client.Metric{} + for _, m := range metrics { + err := m.Write(metric) + if err != nil { + t.Fatalf("Error writing metric: %v", err) + } + if _, ok := c.expectedMetrics[metric.String()]; !ok { + t.Fatalf("Unexpected metric: got %v", metric.String()) + } + if c.expectedMetrics[metric.String()] != m.Desc().String() { + t.Fatalf("Unexpected metric: got %v , want %v", m.Desc().String(), c.expectedMetrics[metric.String()]) + } } } } diff --git a/config/config.go b/config/config.go index 46f26fff..ab85f47c 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "io/ioutil" + "regexp" "strings" "time" @@ -40,6 +41,9 @@ var ( DefaultModule = Module{ WalkParams: DefaultWalkParams, } + DefaultRegexpExtract = RegexpExtract{ + Value: "$1", + } ) // Config for the snmp_exporter. @@ -156,14 +160,13 @@ func (c WalkParams) ConfigureSNMP(g *gosnmp.GoSNMP) { } type Metric struct { - Name string `yaml:"name"` - Oid string `yaml:"oid"` - Type string `yaml:"type"` - Help string `yaml:"help"` - Indexes []*Index `yaml:"indexes,omitempty"` - Lookups []*Lookup `yaml:"lookups,omitempty"` - - XXX map[string]interface{} `yaml:",inline"` + Name string `yaml:"name"` + Oid string `yaml:"oid"` + Type string `yaml:"type"` + Help string `yaml:"help"` + Indexes []*Index `yaml:"indexes,omitempty"` + Lookups []*Lookup `yaml:"lookups,omitempty"` + RegexpExtracts map[string][]RegexpExtract `yaml:"regex_extracts,omitempty"` } func (c *Metric) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -171,9 +174,6 @@ func (c *Metric) UnmarshalYAML(unmarshal func(interface{}) error) error { if err := unmarshal((*plain)(c)); err != nil { return err } - if err := CheckOverflow(c.XXX, "module"); err != nil { - return err - } return nil } @@ -256,6 +256,52 @@ func (c *Auth) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +type RegexpExtract struct { + Value string `yaml:"value"` + Regex Regexp `yaml:"regex"` + + XXX map[string]interface{} `yaml:",inline"` +} + +func (c *RegexpExtract) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultRegexpExtract + type plain RegexpExtract + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if err := CheckOverflow(c.XXX, "regex_extract"); err != nil { + return err + } + return nil +} + +// Regexp encapsulates a regexp.Regexp and makes it YAML marshalable. +type Regexp struct { + *regexp.Regexp +} + +// MarshalYAML implements the yaml.Marshaler interface. +func (re Regexp) MarshalYAML() (interface{}, error) { + if re.Regexp != nil { + return re.String(), nil + } + return nil, nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (re *Regexp) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + regex, err := regexp.Compile("^(?:" + s + ")$") + if err != nil { + return err + } + re.Regexp = regex + return nil +} + func CheckOverflow(m map[string]interface{}, ctx string) error { if len(m) > 0 { var keys []string diff --git a/config_test.go b/config_test.go index df79cda5..9002e628 100644 --- a/config_test.go +++ b/config_test.go @@ -25,3 +25,17 @@ func TestHideConfigSecrets(t *testing.T) { t.Fatal("config's String method reveals authentication credentials.") } } + +func TestLoadConfigWithOverrides(t *testing.T) { + sc := &SafeConfig{} + err := sc.ReloadConfig("testdata/snmp-with-overrides.yml") + if err != nil { + t.Errorf("Error loading config %v: %v", "testdata/snmp-with-overrides.yml", err) + } + sc.RLock() + _, err = yaml.Marshal(sc.C) + sc.RUnlock() + if err != nil { + t.Errorf("Error marshalling config: %v", err) + } +} diff --git a/generator/FORMAT.md b/generator/FORMAT.md index b25a4188..20b514ee 100644 --- a/generator/FORMAT.md +++ b/generator/FORMAT.md @@ -28,7 +28,7 @@ module_name: - name: ifMtu oid: 1.3.6.1.2.1.2.2.1.4 type: gauge - # A list of the tabel indexes and their types. All indexes become labels. + # A list of the table indexes and their types. All indexes become labels. indexes: - labelname: ifIndex type: gauge @@ -45,4 +45,9 @@ module_name: oid: 1.3.6.1.2.1.2.2.1.2 # OID to look under. labelname: ifDescr # Output label name. type: OctetString # Type of output object. + # Creates new metrics based on the regex and the metric value. + regex_extracts: + Temp: # A new metric will be created appending this to the metricName to become metricNameTemp. + - regex: '(.*)' # Regex to extract a value from the returned SNMP walks's value. + value: '$1' # Parsed as float64, defaults to $1. ``` diff --git a/generator/README.md b/generator/README.md index dcc488d4..160b9b12 100644 --- a/generator/README.md +++ b/generator/README.md @@ -71,6 +71,18 @@ modules: # with that value. - old_index: bsnDot11EssIndex new_index: bsnDot11EssSsid + + overrides: # Allows for per-module overrides of bits of MIBs + metricName: + regex_extracts: + Temp: # A new metric will be created appending this to the metricName to become metricNameTemp. + - regex: '(.*)' # Regex to extract a value from the returned SNMP walks's value. + value: '$1' # The result will be parsed as a float64, defaults to $1. + Status: + - regex: '.*Example' + value: '1' + - regex: '.*' + value: '0' ``` ## Where to get MIBs @@ -93,5 +105,3 @@ Put the extracted mibs in a location NetSNMP can read them from. `$HOME/.snmp/mi https://github.com/librenms/librenms/tree/master/mibs can also be a good source of MIBs. http://oidref.com is recommended for browsing MIBs. - - diff --git a/generator/config.go b/generator/config.go index 6cfb640d..1fd1e58a 100644 --- a/generator/config.go +++ b/generator/config.go @@ -20,11 +20,28 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } -type ModuleConfig struct { - Walk []string `yaml:"walk"` - Lookups []*Lookup `yaml:"lookups"` +type MetricOverrides struct { + RegexpExtracts map[string][]config.RegexpExtract `yaml:"regex_extracts,omitempty"` + + XXX map[string]interface{} `yaml:",inline"` +} - WalkParams config.WalkParams `yaml:",inline"` +func (c *MetricOverrides) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain MetricOverrides + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if err := config.CheckOverflow(c.XXX, "overrides"); err != nil { + return err + } + return nil +} + +type ModuleConfig struct { + Walk []string `yaml:"walk"` + Lookups []*Lookup `yaml:"lookups"` + WalkParams config.WalkParams `yaml:",inline"` + Overrides map[string]MetricOverrides `yaml:"overrides"` XXX map[string]interface{} `yaml:",inline"` } diff --git a/generator/tree.go b/generator/tree.go index 25ea9851..d0d6b63c 100644 --- a/generator/tree.go +++ b/generator/tree.go @@ -215,6 +215,15 @@ func generateConfigModule(cfg *ModuleConfig, node *Node, nameToNode map[string]* } } + // Apply module config overrides to their corresponding metrics. + for name, params := range cfg.Overrides { + for _, metric := range out.Metrics { + if name == metric.Name || name == metric.Oid { + metric.RegexpExtracts = params.RegexpExtracts + } + } + } + oids := []string{} for k, _ := range needToWalk { oids = append(oids, k) diff --git a/generator/tree_test.go b/generator/tree_test.go index 5b91f85c..816eb52b 100644 --- a/generator/tree_test.go +++ b/generator/tree_test.go @@ -2,6 +2,7 @@ package main import ( "reflect" + "regexp" "testing" "github.com/prometheus/snmp_exporter/config" @@ -101,11 +102,48 @@ func TestTreePrepare(t *testing.T) { } func TestGenerateConfigModule(t *testing.T) { + var regexpFooBar config.Regexp + regexpFooBar.Regexp, _ = regexp.Compile(".*") + + strMetrics := make(map[string][]config.RegexpExtract) + strMetrics["Status"] = []config.RegexpExtract{ + { + Regex: regexpFooBar, + Value: "5", + }, + } + + overrides := make(map[string]MetricOverrides) + metricOverrides := MetricOverrides{ + RegexpExtracts: strMetrics, + } + overrides["root"] = metricOverrides + cases := []struct { node *Node cfg *ModuleConfig // SNMP generator config. out *config.Module // SNMP exporter config. }{ + // Simple metric with overrides. + { + node: &Node{Oid: "1", Access: "ACCESS_READONLY", Type: "INTEGER", Label: "root"}, + cfg: &ModuleConfig{ + Walk: []string{"root"}, + Overrides: overrides, + }, + out: &config.Module{ + Walk: []string{"1"}, + Metrics: []*config.Metric{ + { + Name: "root", + Oid: "1", + Type: "gauge", + Help: " - 1", + RegexpExtracts: strMetrics, + }, + }, + }, + }, // Simple metric. { node: &Node{Oid: "1", Access: "ACCESS_READONLY", Type: "INTEGER", Label: "root"}, @@ -711,7 +749,6 @@ func TestGenerateConfigModule(t *testing.T) { nameToNode := prepareTree(c.node) got := generateConfigModule(c.cfg, c.node, nameToNode) - if !reflect.DeepEqual(got, c.out) { t.Errorf("GenerateConfigModule: difference in case %d", i) out, _ := yaml.Marshal(got) diff --git a/testdata/snmp-with-overrides.yml b/testdata/snmp-with-overrides.yml new file mode 100644 index 00000000..c38d7ea1 --- /dev/null +++ b/testdata/snmp-with-overrides.yml @@ -0,0 +1,10 @@ +default: + walk: + - 1.1.1.1.1.1 + metrics: + - name: testMetric + oid: 1.1.1.1.1 + type: gauge + regex_extracts: + Temp: + - regex: