diff --git a/collector/runtime.go b/collector/runtime.go new file mode 100644 index 0000000000..4dc25bbc7f --- /dev/null +++ b/collector/runtime.go @@ -0,0 +1,69 @@ +// Copyright 2026 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 ( + "log/slog" + "sort" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/prometheus/node_exporter/config" +) + +// Runtime represents a single exporter runtime instance built from a reusable +// Config. +type Runtime struct { + collector *NodeCollector +} + +func NewRuntime(cfg config.Config, logger *slog.Logger) (*Runtime, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + if cfg.CollectorDisableDefaults { + DisableDefaultCollectors() + } + + nodeCollector, err := NewNodeCollector(logger, cfg.EnabledCollectors...) + if err != nil { + return nil, err + } + + return &Runtime{collector: nodeCollector}, nil +} + +func (r *Runtime) Collectors() []prometheus.Collector { + return []prometheus.Collector{r.collector} +} + +func (r *Runtime) Registry() (*prometheus.Registry, error) { + registry := prometheus.NewRegistry() + for _, c := range r.Collectors() { + if err := registry.Register(c); err != nil { + return nil, err + } + } + return registry, nil +} + +func (r *Runtime) EnabledCollectors() []string { + enabled := make([]string, 0, len(r.collector.Collectors)) + for name := range r.collector.Collectors { + enabled = append(enabled, name) + } + sort.Strings(enabled) + return enabled +} diff --git a/collector/runtime_test.go b/collector/runtime_test.go new file mode 100644 index 0000000000..e3bc30b04e --- /dev/null +++ b/collector/runtime_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 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 ( + "io" + "log/slog" + "testing" + + "github.com/prometheus/node_exporter/config" +) + +func TestNewRuntimeCollectors(t *testing.T) { + runtime, err := NewRuntime(config.NewConfigWithDefaults(), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + if got, want := len(runtime.Collectors()), 1; got != want { + t.Errorf("Expected: %d, Got: %d", want, got) + } + + if got := len(runtime.EnabledCollectors()); got == 0 { + t.Fatal("expected at least one enabled collector") + } +} + +func TestNewRuntimeValidateConfig(t *testing.T) { + cfg := config.NewConfigWithDefaults() + cfg.RuntimeGoMaxProcs = 0 + + if _, err := NewRuntime(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRuntimeRegistry(t *testing.T) { + runtime, err := NewRuntime(config.NewConfigWithDefaults(), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + registry, err := runtime.Registry() + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + metrics, err := registry.Gather() + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if got := len(metrics); got == 0 { + t.Fatal("expected gathered metrics, got none") + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000000..f0f169d33c --- /dev/null +++ b/config/config.go @@ -0,0 +1,54 @@ +// Copyright 2026 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 config + +import "fmt" + +const ( + DefaultWebTelemetryPath = "/metrics" + DefaultWebMaxRequests = 40 + DefaultRuntimeGoMaxProcs = 1 +) + +// Config contains the user-facing configuration currently adapted by the +// node_exporter binary into the reusable runtime and HTTP handler layers. +type Config struct { + WebTelemetryPath string + WebDisableExporterMetrics bool + WebMaxRequests int + CollectorDisableDefaults bool + RuntimeGoMaxProcs int + EnabledCollectors []string +} + +func NewConfigWithDefaults() Config { + return Config{ + WebTelemetryPath: DefaultWebTelemetryPath, + WebMaxRequests: DefaultWebMaxRequests, + RuntimeGoMaxProcs: DefaultRuntimeGoMaxProcs, + } +} + +func (c Config) Validate() error { + if c.WebTelemetryPath == "" { + return fmt.Errorf("web telemetry path must not be empty") + } + if c.WebMaxRequests < 0 { + return fmt.Errorf("web max requests must be greater than or equal to zero") + } + if c.RuntimeGoMaxProcs <= 0 { + return fmt.Errorf("runtime gomaxprocs must be greater than zero") + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000000..a39dbf9521 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 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 config + +import "testing" + +func TestNewConfigWithDefaults(t *testing.T) { + cfg := NewConfigWithDefaults() + + if got, want := cfg.WebTelemetryPath, DefaultWebTelemetryPath; got != want { + t.Errorf("Expected: %q, Got: %q", want, got) + } + if got, want := cfg.WebMaxRequests, DefaultWebMaxRequests; got != want { + t.Errorf("Expected: %d, Got: %d", want, got) + } + if got, want := cfg.RuntimeGoMaxProcs, DefaultRuntimeGoMaxProcs; got != want { + t.Errorf("Expected: %d, Got: %d", want, got) + } +} + +func TestConfigValidate(t *testing.T) { + cfg := NewConfigWithDefaults() + + if err := cfg.Validate(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestConfigValidateRequiresTelemetryPath(t *testing.T) { + cfg := NewConfigWithDefaults() + cfg.WebTelemetryPath = "" + + if err := cfg.Validate(); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestConfigValidateRejectsNegativeMaxRequests(t *testing.T) { + cfg := NewConfigWithDefaults() + cfg.WebMaxRequests = -1 + + if err := cfg.Validate(); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestConfigValidateRejectsNonPositiveGoMaxProcs(t *testing.T) { + cfg := NewConfigWithDefaults() + cfg.RuntimeGoMaxProcs = 0 + + if err := cfg.Validate(); err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/node_exporter.go b/node_exporter.go index 2c0e12ccc1..f2b2bd289c 100644 --- a/node_exporter.go +++ b/node_exporter.go @@ -22,7 +22,7 @@ import ( "os/user" "runtime" "slices" - "sort" + "strconv" "github.com/prometheus/common/promslog" "github.com/prometheus/common/promslog/flag" @@ -37,6 +37,7 @@ import ( "github.com/prometheus/exporter-toolkit/web/kingpinflag" "github.com/prometheus/node_exporter/collector" + "github.com/prometheus/node_exporter/config" ) // handler wraps an unfiltered http.Handler but uses a filtered handler, @@ -44,6 +45,8 @@ import ( // newHandler. type handler struct { unfilteredHandler http.Handler + cfg config.Config + runtime *collector.Runtime // enabledCollectors list is used for logging and filtering enabledCollectors []string // exporterMetricsRegistry is a separate registry for the metrics about @@ -54,11 +57,18 @@ type handler struct { logger *slog.Logger } -func newHandler(includeExporterMetrics bool, maxRequests int, logger *slog.Logger) *handler { +func newHandler(cfg config.Config, logger *slog.Logger) (*handler, error) { + runtime, err := collector.NewRuntime(cfg, logger) + if err != nil { + return nil, err + } + h := &handler{ + cfg: cfg, + runtime: runtime, exporterMetricsRegistry: prometheus.NewRegistry(), - includeExporterMetrics: includeExporterMetrics, - maxRequests: maxRequests, + includeExporterMetrics: !cfg.WebDisableExporterMetrics, + maxRequests: cfg.WebMaxRequests, logger: logger, } if h.includeExporterMetrics { @@ -67,12 +77,12 @@ func newHandler(includeExporterMetrics bool, maxRequests int, logger *slog.Logge promcollectors.NewGoCollector(), ) } - if innerHandler, err := h.innerHandler(); err != nil { - panic(fmt.Sprintf("Couldn't create metrics handler: %s", err)) - } else { - h.unfilteredHandler = innerHandler + innerHandler, err := h.innerHandler() + if err != nil { + return nil, fmt.Errorf("couldn't create metrics handler: %w", err) } - return h + h.unfilteredHandler = innerHandler + return h, nil } // ServeHTTP implements http.Handler. @@ -125,29 +135,32 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // (in which case it will log all the collectors enabled via command-line // flags). func (h *handler) innerHandler(filters ...string) (http.Handler, error) { - nc, err := collector.NewNodeCollector(h.logger, filters...) - if err != nil { - return nil, fmt.Errorf("couldn't create collector: %s", err) + runtime := h.runtime + if len(filters) > 0 { + cfg := h.cfg + cfg.EnabledCollectors = filters + r, err := collector.NewRuntime(cfg, h.logger) + if err != nil { + return nil, fmt.Errorf("couldn't create collector runtime: %w", err) + } + runtime = r } // Only log the creation of an unfiltered handler, which should happen // only once upon startup. if len(filters) == 0 { h.logger.Info("Enabled collectors") - for n := range nc.Collectors { - h.enabledCollectors = append(h.enabledCollectors, n) - } - sort.Strings(h.enabledCollectors) + h.enabledCollectors = runtime.EnabledCollectors() for _, c := range h.enabledCollectors { h.logger.Info(c) } } - r := prometheus.NewRegistry() - r.MustRegister(versioncollector.NewCollector("node_exporter")) - if err := r.Register(nc); err != nil { - return nil, fmt.Errorf("couldn't register node collector: %s", err) + r, err := runtime.Registry() + if err != nil { + return nil, fmt.Errorf("couldn't create collector registry: %w", err) } + r.MustRegister(versioncollector.NewCollector("node_exporter")) var handler http.Handler if h.includeExporterMetrics { @@ -184,7 +197,7 @@ func main() { metricsPath = kingpin.Flag( "web.telemetry-path", "Path under which to expose metrics.", - ).Default("/metrics").String() + ).Default(config.DefaultWebTelemetryPath).String() disableExporterMetrics = kingpin.Flag( "web.disable-exporter-metrics", "Exclude metrics about the exporter itself (promhttp_*, process_*, go_*).", @@ -192,14 +205,14 @@ func main() { maxRequests = kingpin.Flag( "web.max-requests", "Maximum number of parallel scrape requests. Use 0 to disable.", - ).Default("40").Int() + ).Default(strconv.Itoa(config.DefaultWebMaxRequests)).Int() disableDefaultCollectors = kingpin.Flag( "collector.disable-defaults", "Set all collectors to disabled by default.", ).Default("false").Bool() maxProcs = kingpin.Flag( "runtime.gomaxprocs", "The target number of CPUs Go will run on (GOMAXPROCS)", - ).Envar("GOMAXPROCS").Default("1").Int() + ).Envar("GOMAXPROCS").Default(strconv.Itoa(config.DefaultRuntimeGoMaxProcs)).Int() toolkitFlags = kingpinflag.AddFlags(kingpin.CommandLine, ":9100") ) @@ -211,26 +224,40 @@ func main() { kingpin.Parse() logger := promslog.New(promslogConfig) - if *disableDefaultCollectors { - collector.DisableDefaultCollectors() + cfg := config.NewConfigWithDefaults() + cfg.WebTelemetryPath = *metricsPath + cfg.WebDisableExporterMetrics = *disableExporterMetrics + cfg.WebMaxRequests = *maxRequests + cfg.CollectorDisableDefaults = *disableDefaultCollectors + cfg.RuntimeGoMaxProcs = *maxProcs + if err := cfg.Validate(); err != nil { + logger.Error(err.Error()) + os.Exit(1) } + logger.Info("Starting node_exporter", "version", version.Info()) logger.Info("Build context", "build_context", version.BuildContext()) if user, err := user.Current(); err == nil && user.Uid == "0" { logger.Warn("Node Exporter is running as root user. This exporter is designed to run as unprivileged user, root is not required.") } - runtime.GOMAXPROCS(*maxProcs) + runtime.GOMAXPROCS(cfg.RuntimeGoMaxProcs) logger.Debug("Go MAXPROCS", "procs", runtime.GOMAXPROCS(0)) - http.Handle(*metricsPath, newHandler(!*disableExporterMetrics, *maxRequests, logger)) - if *metricsPath != "/" { + handler, err := newHandler(cfg, logger) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + http.Handle(cfg.WebTelemetryPath, handler) + if cfg.WebTelemetryPath != "/" { landingConfig := web.LandingConfig{ Name: "Node Exporter", Description: "Prometheus Node Exporter", Version: version.Info(), Links: []web.LandingLinks{ { - Address: *metricsPath, + Address: cfg.WebTelemetryPath, Text: "Metrics", }, },