diff --git a/collector/ethtool_linux.go b/collector/ethtool_linux.go new file mode 100644 index 00000000..912e31e5 --- /dev/null +++ b/collector/ethtool_linux.go @@ -0,0 +1,237 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build !noethtool + +// The hard work of collecting data from the kernel via the ethtool interfaces is done by +// https://github.com/safchain/ethtool/ +// by Sylvain Afchain. Used under the Apache license. + +package collector + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/procfs/sysfs" + "github.com/safchain/ethtool" + "gopkg.in/alecthomas/kingpin.v2" +) + +var ( + receivedRegex = regexp.MustCompile(`_rx_`) + transmittedRegex = regexp.MustCompile(`_tx_`) + ethtoolFixtures = kingpin.Flag("collector.ethtool.fixtures", "test fixtures to use for ethtool collector end-to-end testing").Default("").String() +) + +type EthtoolStats interface { + Stats(string) (map[string]uint64, error) +} + +type ethtoolStats struct { +} + +func (e *ethtoolStats) Stats(intf string) (map[string]uint64, error) { + return ethtool.Stats(intf) +} + +type ethtoolCollector struct { + fs sysfs.FS + entries map[string]*prometheus.Desc + logger log.Logger + stats EthtoolStats +} + +type EthtoolFixture struct { + fixturePath string +} + +func (e *EthtoolFixture) Stats(intf string) (map[string]uint64, error) { + res := make(map[string]uint64) + + fixtureFile, err := os.Open(filepath.Join(e.fixturePath, intf)) + if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOENT { + // The fixture for this interface doesn't exist. That's OK because it replicates + // an interface that doesn't support ethtool. + return res, nil + } + if err != nil { + return res, err + } + defer fixtureFile.Close() + + scanner := bufio.NewScanner(fixtureFile) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "NIC statistics:") { + continue + } + line = strings.Trim(line, " ") + items := strings.Split(line, ": ") + val, err := strconv.ParseUint(items[1], 10, 64) + if err != nil { + return res, err + } + res[items[0]] = val + } + + return res, err +} + +func NewEthtoolTestCollector(logger log.Logger) (Collector, error) { + collector, err := makeEthtoolCollector(logger) + collector.stats = &EthtoolFixture{ + fixturePath: *ethtoolFixtures, + } + if err != nil { + return nil, err + } + return collector, nil +} + +// makeEthtoolCollector is the internal constructor for EthtoolCollector. +// This allows NewEthtoolTestCollector to override it's .stats interface +// for testing. +func makeEthtoolCollector(logger log.Logger) (*ethtoolCollector, error) { + fs, err := sysfs.NewFS(*sysPath) + if err != nil { + return nil, fmt.Errorf("failed to open sysfs: %w", err) + } + + // Pre-populate some common ethtool metrics. + return ðtoolCollector{ + fs: fs, + stats: ðtoolStats{}, + entries: map[string]*prometheus.Desc{ + "rx_bytes": prometheus.NewDesc( + "node_ethtool_received_bytes_total", + "Network interface bytes received", + []string{"device"}, nil, + ), + "rx_dropped": prometheus.NewDesc( + "node_ethtool_received_dropped_total", + "Number of received frames dropped", + []string{"device"}, nil, + ), + "rx_errors": prometheus.NewDesc( + "node_ethtool_received_errors_total", + "Number of received frames with errors", + []string{"device"}, nil, + ), + "rx_packets": prometheus.NewDesc( + "node_ethtool_received_packets_total", + "Network interface packets received", + []string{"device"}, nil, + ), + "tx_bytes": prometheus.NewDesc( + "node_ethtool_transmitted_bytes_total", + "Network interface bytes sent", + []string{"device"}, nil, + ), + "tx_errors": prometheus.NewDesc( + "node_ethtool_transmitted_errors_total", + "Number of sent frames with errors", + []string{"device"}, nil, + ), + "tx_packets": prometheus.NewDesc( + "node_ethtool_transmitted_packets_total", + "Network interface packets sent", + []string{"device"}, nil, + ), + }, + logger: logger, + }, nil +} + +func init() { + registerCollector("ethtool", defaultDisabled, NewEthtoolCollector) +} + +// NewEthtoolCollector returns a new Collector exposing ethtool stats. +func NewEthtoolCollector(logger log.Logger) (Collector, error) { + // Specifying --collector.ethtool.fixtures on the command line activates + // the test fixtures. This is for `end-to-end-test.sh` + if *ethtoolFixtures != "" { + return NewEthtoolTestCollector(logger) + } + return makeEthtoolCollector(logger) +} + +func (c *ethtoolCollector) Update(ch chan<- prometheus.Metric) error { + netClass, err := c.fs.NetClass() + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + level.Debug(c.logger).Log("msg", "Could not read netclass file", "err", err) + return ErrNoData + } + return fmt.Errorf("could not get net class info: %w", err) + } + + if len(netClass) == 0 { + return fmt.Errorf("no network devices found") + } + + for device := range netClass { + var stats map[string]uint64 + var err error + + stats, err = c.stats.Stats(device) + if err != nil { + // Suppressing errors because it's hard to tell what interfaces support ethtool and which don't. + continue + } + + // Sort metric names so that the test fixtures will match up + keys := make([]string, 0, len(stats)) + for k := range stats { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, metric := range keys { + val := stats[metric] + metricFQName := prometheus.BuildFQName(namespace, "ethtool", metric) + metricFQName = receivedRegex.ReplaceAllString(metricFQName, "_received_") + metricFQName = transmittedRegex.ReplaceAllString(metricFQName, "_transmitted_") + + // Check to see if this metric exists; if not then create it and store it in c.entries. + entry, exists := c.entries[metric] + if !exists { + entry = prometheus.NewDesc( + metricFQName, + fmt.Sprintf("Network interface %s", metric), + []string{"device"}, nil, + ) + c.entries[metric] = entry + } + ch <- prometheus.MustNewConstMetric( + entry, prometheus.UntypedValue, float64(val), device) + } + } + + return nil +} diff --git a/collector/ethtool_linux_test.go b/collector/ethtool_linux_test.go new file mode 100644 index 00000000..28f1b70e --- /dev/null +++ b/collector/ethtool_linux_test.go @@ -0,0 +1,70 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package collector + +import ( + "fmt" + "testing" + + "github.com/go-kit/kit/log" + "github.com/prometheus/client_golang/prometheus" +) + +func TestEthtoolCollector(t *testing.T) { + testcases := []string{ + prometheus.NewDesc("node_ethtool_align_errors", "Network interface align_errors", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_broadcast", "Network interface rx_broadcast", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_errors_total", "Number of received frames with errors", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_missed", "Network interface rx_missed", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_multicast", "Network interface rx_multicast", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_packets_total", "Network interface packets received", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_received_unicast", "Network interface rx_unicast", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_transmitted_aborted", "Network interface tx_aborted", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_transmitted_errors_total", "Number of sent frames with errors", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_transmitted_multi_collisions", "Network interface tx_multi_collisions", []string{"device"}, nil).String(), + prometheus.NewDesc("node_ethtool_transmitted_packets_total", "Network interface packets sent", []string{"device"}, nil).String(), + } + + *sysPath = "fixtures/sys" + *ethtoolFixtures = "fixtures/ethtool/" + + collector, err := NewEthtoolTestCollector(log.NewNopLogger()) + if err != nil { + panic(err) + } + + sink := make(chan prometheus.Metric) + go func() { + err = collector.Update(sink) + if err != nil { + panic(fmt.Errorf("failed to update collector: %s", err)) + } + close(sink) + }() + + for _, expected := range testcases { + metric := (<-sink) + if metric == nil { + t.Fatalf("Expected '%s' but got nothing (nil).", expected) + } + + got := metric.Desc().String() + metric.Desc() + if expected != got { + t.Errorf("Expected '%s' but got '%s'", expected, got) + } else { + t.Logf("Successfully got '%s'", got) + } + } +} diff --git a/collector/fixtures/e2e-output.txt b/collector/fixtures/e2e-output.txt index e04cb0cc..5f3c3e0e 100644 --- a/collector/fixtures/e2e-output.txt +++ b/collector/fixtures/e2e-output.txt @@ -657,6 +657,45 @@ node_entropy_available_bits 1337 # HELP node_entropy_pool_size_bits Bits of entropy pool. # TYPE node_entropy_pool_size_bits gauge node_entropy_pool_size_bits 4096 +# HELP node_ethtool_align_errors Network interface align_errors +# TYPE node_ethtool_align_errors untyped +node_ethtool_align_errors{device="eth0"} 0 +# HELP node_ethtool_received_broadcast Network interface rx_broadcast +# TYPE node_ethtool_received_broadcast untyped +node_ethtool_received_broadcast{device="eth0"} 5792 +# HELP node_ethtool_received_errors_total Number of received frames with errors +# TYPE node_ethtool_received_errors_total untyped +node_ethtool_received_errors_total{device="eth0"} 0 +# HELP node_ethtool_received_missed Network interface rx_missed +# TYPE node_ethtool_received_missed untyped +node_ethtool_received_missed{device="eth0"} 401 +# HELP node_ethtool_received_multicast Network interface rx_multicast +# TYPE node_ethtool_received_multicast untyped +node_ethtool_received_multicast{device="eth0"} 23973 +# HELP node_ethtool_received_packets_total Network interface packets received +# TYPE node_ethtool_received_packets_total untyped +node_ethtool_received_packets_total{device="eth0"} 1.260062e+06 +# HELP node_ethtool_received_unicast Network interface rx_unicast +# TYPE node_ethtool_received_unicast untyped +node_ethtool_received_unicast{device="eth0"} 1.230297e+06 +# HELP node_ethtool_transmitted_aborted Network interface tx_aborted +# TYPE node_ethtool_transmitted_aborted untyped +node_ethtool_transmitted_aborted{device="eth0"} 0 +# HELP node_ethtool_transmitted_errors_total Number of sent frames with errors +# TYPE node_ethtool_transmitted_errors_total untyped +node_ethtool_transmitted_errors_total{device="eth0"} 0 +# HELP node_ethtool_transmitted_multi_collisions Network interface tx_multi_collisions +# TYPE node_ethtool_transmitted_multi_collisions untyped +node_ethtool_transmitted_multi_collisions{device="eth0"} 0 +# HELP node_ethtool_transmitted_packets_total Network interface packets sent +# TYPE node_ethtool_transmitted_packets_total untyped +node_ethtool_transmitted_packets_total{device="eth0"} 961500 +# HELP node_ethtool_transmitted_single_collisions Network interface tx_single_collisions +# TYPE node_ethtool_transmitted_single_collisions untyped +node_ethtool_transmitted_single_collisions{device="eth0"} 0 +# HELP node_ethtool_transmitted_underrun Network interface tx_underrun +# TYPE node_ethtool_transmitted_underrun untyped +node_ethtool_transmitted_underrun{device="eth0"} 0 # HELP node_exporter_build_info A metric with a constant '1' value labeled by version, revision, branch, and goversion from which node_exporter was built. # TYPE node_exporter_build_info gauge # HELP node_fibrechannel_error_frames_total Number of errors in frames @@ -2745,6 +2784,7 @@ node_scrape_collector_success{collector="diskstats"} 1 node_scrape_collector_success{collector="drbd"} 1 node_scrape_collector_success{collector="edac"} 1 node_scrape_collector_success{collector="entropy"} 1 +node_scrape_collector_success{collector="ethtool"} 1 node_scrape_collector_success{collector="fibrechannel"} 1 node_scrape_collector_success{collector="filefd"} 1 node_scrape_collector_success{collector="hwmon"} 1 diff --git a/collector/fixtures/ethtool/eth0 b/collector/fixtures/ethtool/eth0 new file mode 100644 index 00000000..9ce7e015 --- /dev/null +++ b/collector/fixtures/ethtool/eth0 @@ -0,0 +1,15 @@ +# ethtool -S eth0 +NIC statistics: + tx_packets: 961500 + rx_packets: 1260062 + tx_errors: 0 + rx_errors: 0 + rx_missed: 401 + align_errors: 0 + tx_single_collisions: 0 + tx_multi_collisions: 0 + rx_unicast: 1230297 + rx_broadcast: 5792 + rx_multicast: 23973 + tx_aborted: 0 + tx_underrun: 0 diff --git a/end-to-end-test.sh b/end-to-end-test.sh index d87162fe..9de1e980 100755 --- a/end-to-end-test.sh +++ b/end-to-end-test.sh @@ -14,6 +14,7 @@ enabled_collectors=$(cat << COLLECTORS drbd edac entropy + ethtool fibrechannel filefd hwmon @@ -109,6 +110,7 @@ fi --collector.qdisc.fixtures="collector/fixtures/qdisc/" \ --collector.netclass.ignored-devices="(dmz|int)" \ --collector.netclass.ignore-invalid-speed \ + --collector.ethtool.fixtures="collector/fixtures/ethtool/" \ --collector.bcache.priorityStats \ --collector.cpu.info \ --collector.cpu.info.flags-include="^(aes|avx.?|constant_tsc)$" \ diff --git a/go.mod b/go.mod index 13c36ebc..a79dcb0e 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/prometheus/common v0.15.0 github.com/prometheus/exporter-toolkit v0.5.1 github.com/prometheus/procfs v0.6.0 + github.com/safchain/ethtool v0.0.0-20200804214954-8f958a28363a github.com/siebenmann/go-kstat v0.0.0-20200303194639-4e8294f9e9d5 github.com/soundcloud/go-runit v0.0.0-20150630195641-06ad41a06c4a golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c diff --git a/go.sum b/go.sum index 37f928be..9a4efe45 100644 --- a/go.sum +++ b/go.sum @@ -285,6 +285,8 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/safchain/ethtool v0.0.0-20200804214954-8f958a28363a h1:TXFp1qmI50hk8dfGl7o8PKT9Lxo84T7RlpKCOB1DndI= +github.com/safchain/ethtool v0.0.0-20200804214954-8f958a28363a/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=