From f2787ace72c96a45b5862ee8b80af570a6c8aa93 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 19 May 2026 16:03:54 +0100 Subject: [PATCH 1/6] config: add reusable exporter config package Signed-off-by: Nicolas Takashi --- config/config.go | 54 +++++++++++++++++++++++++++++++++++ config/config_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 config/config.go create mode 100644 config/config_test.go 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") + } +} From bd1a15ee90a8ce7a13e950804c37b871d51f5aa2 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 19 May 2026 16:16:33 +0100 Subject: [PATCH 2/6] collector: add reusable runtime wrapper Signed-off-by: Nicolas Takashi --- collector/runtime.go | 69 +++++++++++++++++++++++++++++++++++++++ collector/runtime_test.go | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 collector/runtime.go create mode 100644 collector/runtime_test.go 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") + } +} From 0a881ca9dc57d412492d3c02b77ebed284de66f0 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 19 May 2026 20:13:19 +0100 Subject: [PATCH 3/6] main: wire node_exporter through reusable config and runtime Signed-off-by: Nicolas Takashi --- node_exporter.go | 85 +++++++++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 29 deletions(-) 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", }, }, From f2d8a9df129ee108837a4093aeed71b946dcf60c Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Tue, 19 May 2026 20:30:05 +0100 Subject: [PATCH 4/6] collector: scope collector runtime state Signed-off-by: Nicolas Takashi --- collector/collector.go | 62 ++++++++++++++++++++++++++------------- collector/cpu_linux.go | 10 ++++--- collector/runtime.go | 21 ++++++++++--- collector/runtime_test.go | 53 +++++++++++++++++++++++++++++++++ node_exporter.go | 4 +-- 5 files changed, 119 insertions(+), 31 deletions(-) diff --git a/collector/collector.go b/collector/collector.go index 18ff7388ca..9e6736ef88 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -49,13 +49,21 @@ const ( ) var ( - factories = make(map[string]func(logger *slog.Logger) (Collector, error)) - initiatedCollectorsMtx = sync.Mutex{} - initiatedCollectors = make(map[string]Collector) - collectorState = make(map[string]*bool) - forcedCollectors = map[string]bool{} // collectors which have been explicitly enabled or disabled + factories = make(map[string]func(logger *slog.Logger) (Collector, error)) + collectorState = make(map[string]*bool) + forcedCollectors = map[string]bool{} // collectors which have been explicitly enabled or disabled ) +type collectorRuntimeState struct { + enabled map[string]bool + collectors map[string]Collector + mtx sync.Mutex +} + +type runtimeConfiguredCollector interface { + configureRuntimeState(*collectorRuntimeState) +} + func registerCollector(collector string, isDefaultEnabled bool, factory func(logger *slog.Logger) (Collector, error)) { var helpDefaultState string if isDefaultEnabled { @@ -80,14 +88,21 @@ type NodeCollector struct { logger *slog.Logger } -// DisableDefaultCollectors sets the collector state to false for all collectors which -// have not been explicitly enabled on the command line. -func DisableDefaultCollectors() { - for c := range collectorState { - if _, ok := forcedCollectors[c]; !ok { - *collectorState[c] = false +func newCollectorRuntimeState(disableDefaultCollectors bool) *collectorRuntimeState { + enabled := make(map[string]bool, len(collectorState)) + for collector, state := range collectorState { + enabled[collector] = *state + if disableDefaultCollectors { + if _, ok := forcedCollectors[collector]; !ok { + enabled[collector] = false + } } } + + return &collectorRuntimeState{ + enabled: enabled, + collectors: make(map[string]Collector), + } } // collectorFlagAction generates a new action function for the given collector @@ -103,39 +118,46 @@ func collectorFlagAction(collector string) func(ctx *kingpin.ParseContext) error } // NewNodeCollector creates a new NodeCollector. -func NewNodeCollector(logger *slog.Logger, filters ...string) (*NodeCollector, error) { +func (s *collectorRuntimeState) NewNodeCollector(logger *slog.Logger, filters ...string) (*NodeCollector, error) { f := make(map[string]bool) for _, filter := range filters { - enabled, exist := collectorState[filter] + enabled, exist := s.enabled[filter] if !exist { return nil, fmt.Errorf("missing collector: %s", filter) } - if !*enabled { + if !enabled { return nil, fmt.Errorf("disabled collector: %s", filter) } f[filter] = true } collectors := make(map[string]Collector) - initiatedCollectorsMtx.Lock() - defer initiatedCollectorsMtx.Unlock() - for key, enabled := range collectorState { - if !*enabled || (len(f) > 0 && !f[key]) { + s.mtx.Lock() + defer s.mtx.Unlock() + for key, enabled := range s.enabled { + if !enabled || (len(f) > 0 && !f[key]) { continue } - if collector, ok := initiatedCollectors[key]; ok { + if collector, ok := s.collectors[key]; ok { collectors[key] = collector } else { collector, err := factories[key](logger.With("collector", key)) if err != nil { return nil, err } + s.configureCollector(collector) collectors[key] = collector - initiatedCollectors[key] = collector + s.collectors[key] = collector } } return &NodeCollector{Collectors: collectors, logger: logger}, nil } +func (s *collectorRuntimeState) configureCollector(collector Collector) { + if configuredCollector, ok := collector.(runtimeConfiguredCollector); ok { + configuredCollector.configureRuntimeState(s) + } +} + // Describe implements the prometheus.Collector interface. func (n NodeCollector) Describe(ch chan<- *prometheus.Desc) { ch <- scrapeDurationDesc diff --git a/collector/cpu_linux.go b/collector/cpu_linux.go index 30fe305ac0..3a4647aaa1 100644 --- a/collector/cpu_linux.go +++ b/collector/cpu_linux.go @@ -50,6 +50,7 @@ type cpuCollector struct { cpuStats map[int64]procfs.CPUStat cpuStatsMutex sync.Mutex isolatedCpus []uint16 + cpuFreqEnabled bool cpuFlagsIncludeRegexp *regexp.Regexp cpuBugsIncludeRegexp *regexp.Regexp @@ -150,6 +151,10 @@ func NewCPUCollector(logger *slog.Logger) (Collector, error) { return c, nil } +func (c *cpuCollector) configureRuntimeState(state *collectorRuntimeState) { + c.cpuFreqEnabled = state.enabled["cpufreq"] +} + func (c *cpuCollector) compileIncludeFlags(flagsIncludeFlag, bugsIncludeFlag *string) error { if (*flagsIncludeFlag != "" || *bugsIncludeFlag != "") && !*enableCPUInfo { *enableCPUInfo = true @@ -219,10 +224,7 @@ func (c *cpuCollector) updateInfo(ch chan<- prometheus.Metric) error { cpu.CacheSize) } - cpuFreqEnabled, ok := collectorState["cpufreq"] - if !ok || cpuFreqEnabled == nil { - c.logger.Debug("cpufreq key missing or nil value in collectorState map") - } else if *cpuFreqEnabled { + if c.cpuFreqEnabled { for _, cpu := range info { ch <- prometheus.MustNewConstMetric(c.cpuFrequencyHz, prometheus.GaugeValue, diff --git a/collector/runtime.go b/collector/runtime.go index 4dc25bbc7f..2b5bbd6d9e 100644 --- a/collector/runtime.go +++ b/collector/runtime.go @@ -25,7 +25,9 @@ import ( // Runtime represents a single exporter runtime instance built from a reusable // Config. type Runtime struct { + state *collectorRuntimeState collector *NodeCollector + logger *slog.Logger } func NewRuntime(cfg config.Config, logger *slog.Logger) (*Runtime, error) { @@ -33,16 +35,27 @@ func NewRuntime(cfg config.Config, logger *slog.Logger) (*Runtime, error) { return nil, err } - if cfg.CollectorDisableDefaults { - DisableDefaultCollectors() + state := newCollectorRuntimeState(cfg.CollectorDisableDefaults) + + return newRuntime(state, logger, cfg.EnabledCollectors) +} + +func newRuntime(state *collectorRuntimeState, logger *slog.Logger, enabledCollectors []string) (*Runtime, error) { + nodeCollector, err := state.NewNodeCollector(logger, enabledCollectors...) + if err != nil { + return nil, err } - nodeCollector, err := NewNodeCollector(logger, cfg.EnabledCollectors...) + return &Runtime{state: state, collector: nodeCollector, logger: logger}, nil +} + +func (r *Runtime) Filtered(enabledCollectors ...string) (*Runtime, error) { + filtered, err := newRuntime(r.state, r.logger, enabledCollectors) if err != nil { return nil, err } - return &Runtime{collector: nodeCollector}, nil + return filtered, nil } func (r *Runtime) Collectors() []prometheus.Collector { diff --git a/collector/runtime_test.go b/collector/runtime_test.go index e3bc30b04e..8616ed5ba1 100644 --- a/collector/runtime_test.go +++ b/collector/runtime_test.go @@ -64,3 +64,56 @@ func TestRuntimeRegistry(t *testing.T) { t.Fatal("expected gathered metrics, got none") } } + +func TestNewRuntimeDisableDefaultsDoesNotMutateGlobalCollectorState(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + disabledDefaultsCfg := config.NewConfigWithDefaults() + disabledDefaultsCfg.CollectorDisableDefaults = true + + if _, err := NewRuntime(disabledDefaultsCfg, logger); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + runtime, err := NewRuntime(config.NewConfigWithDefaults(), logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + if got := len(runtime.EnabledCollectors()); got == 0 { + t.Fatal("expected enabled collectors after constructing runtime with defaults") + } +} + +func TestRuntimeFilteredUsesBaseCollectorState(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + originalCPUEnabled := *collectorState["cpu"] + _, originalCPUForced := forcedCollectors["cpu"] + *collectorState["cpu"] = true + forcedCollectors["cpu"] = true + t.Cleanup(func() { + *collectorState["cpu"] = originalCPUEnabled + if originalCPUForced { + forcedCollectors["cpu"] = true + } else { + delete(forcedCollectors, "cpu") + } + }) + + cfg := config.NewConfigWithDefaults() + cfg.CollectorDisableDefaults = true + cfg.EnabledCollectors = []string{"cpu"} + + runtime, err := NewRuntime(cfg, logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + if _, err := runtime.Filtered("cpu"); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + if _, err := runtime.Filtered("meminfo"); err == nil { + t.Fatal("expected error for collector disabled in base runtime") + } +} diff --git a/node_exporter.go b/node_exporter.go index f2b2bd289c..4a8e07c5a5 100644 --- a/node_exporter.go +++ b/node_exporter.go @@ -137,9 +137,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) innerHandler(filters ...string) (http.Handler, error) { runtime := h.runtime if len(filters) > 0 { - cfg := h.cfg - cfg.EnabledCollectors = filters - r, err := collector.NewRuntime(cfg, h.logger) + r, err := h.runtime.Filtered(filters...) if err != nil { return nil, fmt.Errorf("couldn't create collector runtime: %w", err) } From d75fe550ccefdb80c609bed4f675bb886cf8db41 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Wed, 20 May 2026 11:14:16 +0100 Subject: [PATCH 5/6] collector: pass cpu and textfile config through runtime Signed-off-by: Nicolas Takashi --- collector/collector.go | 46 +++++++++++++-- collector/cpu_config_other.go | 20 +++++++ collector/cpu_linux.go | 95 ++++++++++++++++++------------ collector/paths.go | 9 +++ collector/runtime_linux_test.go | 84 ++++++++++++++++++++++++++ collector/runtime_textfile_test.go | 63 ++++++++++++++++++++ collector/textfile.go | 10 +++- collector/textfile_config_stub.go | 20 +++++++ 8 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 collector/cpu_config_other.go create mode 100644 collector/runtime_linux_test.go create mode 100644 collector/runtime_textfile_test.go create mode 100644 collector/textfile_config_stub.go diff --git a/collector/collector.go b/collector/collector.go index 9e6736ef88..fe94cd4638 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -55,13 +55,38 @@ var ( ) type collectorRuntimeState struct { + config collectorConfig enabled map[string]bool collectors map[string]Collector mtx sync.Mutex } type runtimeConfiguredCollector interface { - configureRuntimeState(*collectorRuntimeState) + configureRuntimeState(*collectorRuntimeState) error +} + +type collectorConfig struct { + paths collectorPathConfig + cpu cpuCollectorConfig + textfile textFileCollectorConfig +} + +type collectorPathConfig struct { + procMountPoint string + sysMountPoint string + rootfsPath string + udevDataPath string +} + +type cpuCollectorConfig struct { + guestEnabled bool + infoEnabled bool + flagsInclude string + bugsInclude string +} + +type textFileCollectorConfig struct { + directories []string } func registerCollector(collector string, isDefaultEnabled bool, factory func(logger *slog.Logger) (Collector, error)) { @@ -100,11 +125,20 @@ func newCollectorRuntimeState(disableDefaultCollectors bool) *collectorRuntimeSt } return &collectorRuntimeState{ + config: newCollectorConfig(), enabled: enabled, collectors: make(map[string]Collector), } } +func newCollectorConfig() collectorConfig { + return collectorConfig{ + paths: newCollectorPathConfig(), + cpu: newCPUCollectorConfig(), + textfile: newTextFileCollectorConfig(), + } +} + // collectorFlagAction generates a new action function for the given collector // to track whether it has been explicitly enabled or disabled from the command line. // A new action function is needed for each collector flag because the ParseContext @@ -144,7 +178,9 @@ func (s *collectorRuntimeState) NewNodeCollector(logger *slog.Logger, filters .. if err != nil { return nil, err } - s.configureCollector(collector) + if err := s.configureCollector(collector); err != nil { + return nil, err + } collectors[key] = collector s.collectors[key] = collector } @@ -152,10 +188,12 @@ func (s *collectorRuntimeState) NewNodeCollector(logger *slog.Logger, filters .. return &NodeCollector{Collectors: collectors, logger: logger}, nil } -func (s *collectorRuntimeState) configureCollector(collector Collector) { +func (s *collectorRuntimeState) configureCollector(collector Collector) error { if configuredCollector, ok := collector.(runtimeConfiguredCollector); ok { - configuredCollector.configureRuntimeState(s) + return configuredCollector.configureRuntimeState(s) } + + return nil } // Describe implements the prometheus.Collector interface. diff --git a/collector/cpu_config_other.go b/collector/cpu_config_other.go new file mode 100644 index 0000000000..41451e988a --- /dev/null +++ b/collector/cpu_config_other.go @@ -0,0 +1,20 @@ +// 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. + +//go:build !linux || nocpu + +package collector + +func newCPUCollectorConfig() cpuCollectorConfig { + return cpuCollectorConfig{} +} diff --git a/collector/cpu_linux.go b/collector/cpu_linux.go index 3a4647aaa1..6a3ec2c5c0 100644 --- a/collector/cpu_linux.go +++ b/collector/cpu_linux.go @@ -36,6 +36,7 @@ import ( type cpuCollector struct { procfs procfs.FS sysfs sysfs.FS + sysMountPoint string cpu *prometheus.Desc cpuInfo *prometheus.Desc cpuFrequencyHz *prometheus.Desc @@ -51,6 +52,8 @@ type cpuCollector struct { cpuStatsMutex sync.Mutex isolatedCpus []uint16 cpuFreqEnabled bool + guestEnabled bool + infoEnabled bool cpuFlagsIncludeRegexp *regexp.Regexp cpuBugsIncludeRegexp *regexp.Regexp @@ -67,34 +70,23 @@ var ( jumpBackDebugMessage = fmt.Sprintf("CPU Idle counter jumped backwards more than %f seconds, possible hotplug event, resetting CPU stats", jumpBackSeconds) ) +func newCPUCollectorConfig() cpuCollectorConfig { + return cpuCollectorConfig{ + guestEnabled: *enableCPUGuest, + infoEnabled: *enableCPUInfo, + flagsInclude: *flagsInclude, + bugsInclude: *bugsInclude, + } +} + func init() { registerCollector("cpu", defaultEnabled, NewCPUCollector) } // NewCPUCollector returns a new Collector exposing kernel/system statistics. func NewCPUCollector(logger *slog.Logger) (Collector, error) { - pfs, err := procfs.NewFS(*procPath) - if err != nil { - return nil, fmt.Errorf("failed to open procfs: %w", err) - } - - sfs, err := sysfs.NewFS(*sysPath) - if err != nil { - return nil, fmt.Errorf("failed to open sysfs: %w", err) - } - - isolcpus, err := sfs.IsolatedCPUs() - if err != nil { - if !os.IsNotExist(err) { - return nil, fmt.Errorf("unable to get isolated cpus: %w", err) - } - logger.Debug("couldn't open isolated file", "error", err) - } - c := &cpuCollector{ - procfs: pfs, - sysfs: sfs, - cpu: nodeCPUSecondsDesc, + cpu: nodeCPUSecondsDesc, cpuInfo: prometheus.NewDesc( prometheus.BuildFQName(namespace, cpuCollectorSubsystem, "info"), "CPU information from /proc/cpuinfo.", @@ -140,36 +132,61 @@ func NewCPUCollector(logger *slog.Logger) (Collector, error) { "CPUs that are online and being scheduled.", []string{"cpu"}, nil, ), - logger: logger, - isolatedCpus: isolcpus, - cpuStats: make(map[int64]procfs.CPUStat), - } - err = c.compileIncludeFlags(flagsInclude, bugsInclude) - if err != nil { - return nil, fmt.Errorf("fail to compile --collector.cpu.info.flags-include and --collector.cpu.info.bugs-include, the values of them must be regular expressions: %w", err) + logger: logger, + cpuStats: make(map[int64]procfs.CPUStat), } return c, nil } -func (c *cpuCollector) configureRuntimeState(state *collectorRuntimeState) { +func (c *cpuCollector) configureRuntimeState(state *collectorRuntimeState) error { + pfs, err := procfs.NewFS(state.config.paths.procMountPoint) + if err != nil { + return fmt.Errorf("failed to open procfs: %w", err) + } + + sfs, err := sysfs.NewFS(state.config.paths.sysMountPoint) + if err != nil { + return fmt.Errorf("failed to open sysfs: %w", err) + } + + isolcpus, err := sfs.IsolatedCPUs() + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("unable to get isolated cpus: %w", err) + } + c.logger.Debug("couldn't open isolated file", "error", err) + } + + c.procfs = pfs + c.sysfs = sfs + c.sysMountPoint = state.config.paths.sysMountPoint + c.isolatedCpus = isolcpus c.cpuFreqEnabled = state.enabled["cpufreq"] + c.guestEnabled = state.config.cpu.guestEnabled + c.infoEnabled = state.config.cpu.infoEnabled + + if err := c.compileIncludeFlags(state.config.cpu); err != nil { + return fmt.Errorf("fail to compile --collector.cpu.info.flags-include and --collector.cpu.info.bugs-include, the values of them must be regular expressions: %w", err) + } + + return nil } -func (c *cpuCollector) compileIncludeFlags(flagsIncludeFlag, bugsIncludeFlag *string) error { - if (*flagsIncludeFlag != "" || *bugsIncludeFlag != "") && !*enableCPUInfo { - *enableCPUInfo = true +func (c *cpuCollector) compileIncludeFlags(cfg cpuCollectorConfig) error { + if (cfg.flagsInclude != "" || cfg.bugsInclude != "") && !c.infoEnabled { + c.infoEnabled = true c.logger.Info("--collector.cpu.info has been set to `true` because you set the following flags, like --collector.cpu.info.flags-include and --collector.cpu.info.bugs-include") } var err error - if *flagsIncludeFlag != "" { - c.cpuFlagsIncludeRegexp, err = regexp.Compile(*flagsIncludeFlag) + if cfg.flagsInclude != "" { + c.cpuFlagsIncludeRegexp, err = regexp.Compile(cfg.flagsInclude) if err != nil { return err } } - if *bugsIncludeFlag != "" { - c.cpuBugsIncludeRegexp, err = regexp.Compile(*bugsIncludeFlag) + if cfg.bugsInclude != "" { + c.cpuBugsIncludeRegexp, err = regexp.Compile(cfg.bugsInclude) if err != nil { return err } @@ -179,7 +196,7 @@ func (c *cpuCollector) compileIncludeFlags(flagsIncludeFlag, bugsIncludeFlag *st // Update implements Collector and exposes cpu related metrics from /proc/stat and /sys/.../cpu/. func (c *cpuCollector) Update(ch chan<- prometheus.Metric) error { - if *enableCPUInfo { + if c.infoEnabled { if err := c.updateInfo(ch); err != nil { return err } @@ -268,7 +285,7 @@ func updateFieldInfo(valueList []string, filter *regexp.Regexp, desc *prometheus // updateThermalThrottle reads /sys/devices/system/cpu/cpu* and expose thermal throttle statistics. func (c *cpuCollector) updateThermalThrottle(ch chan<- prometheus.Metric) error { - cpus, err := filepath.Glob(sysFilePath("devices/system/cpu/cpu[0-9]*")) + cpus, err := filepath.Glob(filepath.Join(c.sysMountPoint, "devices/system/cpu/cpu[0-9]*")) if err != nil { return err } @@ -397,7 +414,7 @@ func (c *cpuCollector) updateStat(ch chan<- prometheus.Metric) error { ch <- prometheus.MustNewConstMetric(c.cpu, prometheus.CounterValue, cpuStat.SoftIRQ, cpuNum, "softirq") ch <- prometheus.MustNewConstMetric(c.cpu, prometheus.CounterValue, cpuStat.Steal, cpuNum, "steal") - if *enableCPUGuest { + if c.guestEnabled { // Guest CPU is also accounted for in cpuStat.User and cpuStat.Nice, expose these as separate metrics. ch <- prometheus.MustNewConstMetric(c.cpuGuest, prometheus.CounterValue, cpuStat.Guest, cpuNum, "user") ch <- prometheus.MustNewConstMetric(c.cpuGuest, prometheus.CounterValue, cpuStat.GuestNice, cpuNum, "nice") diff --git a/collector/paths.go b/collector/paths.go index 82c941876d..a1d42fd2bf 100644 --- a/collector/paths.go +++ b/collector/paths.go @@ -29,6 +29,15 @@ var ( udevDataPath = kingpin.Flag("path.udev.data", "udev data path.").Default("/run/udev/data").String() ) +func newCollectorPathConfig() collectorPathConfig { + return collectorPathConfig{ + procMountPoint: *procPath, + sysMountPoint: *sysPath, + rootfsPath: *rootfsPath, + udevDataPath: *udevDataPath, + } +} + func procFilePath(name string) string { return filepath.Join(*procPath, name) } diff --git a/collector/runtime_linux_test.go b/collector/runtime_linux_test.go new file mode 100644 index 0000000000..1b86943b7a --- /dev/null +++ b/collector/runtime_linux_test.go @@ -0,0 +1,84 @@ +// 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. + +//go:build linux && !nocpu + +package collector + +import ( + "io" + "log/slog" + "testing" + + "github.com/prometheus/node_exporter/config" +) + +func TestRuntimeSnapshotsCPUCollectorConfig(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + originalGuestEnabled := *enableCPUGuest + originalInfoEnabled := *enableCPUInfo + originalFlagsInclude := *flagsInclude + originalBugsInclude := *bugsInclude + t.Cleanup(func() { + *enableCPUGuest = originalGuestEnabled + *enableCPUInfo = originalInfoEnabled + *flagsInclude = originalFlagsInclude + *bugsInclude = originalBugsInclude + }) + + *enableCPUGuest = false + *enableCPUInfo = false + *flagsInclude = "foo" + *bugsInclude = "" + + cfg := config.NewConfigWithDefaults() + cfg.EnabledCollectors = []string{"cpu"} + + runtime, err := NewRuntime(cfg, logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + *enableCPUGuest = true + *enableCPUInfo = true + *flagsInclude = "bar" + + filtered, err := runtime.Filtered("cpu") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + collector := filtered.collector.Collectors["cpu"].(*cpuCollector) + if collector.guestEnabled { + t.Fatal("expected filtered runtime to keep initial cpu guest setting") + } + if !collector.infoEnabled { + t.Fatal("expected flags include to enable cpu info in filtered runtime") + } + if collector.cpuFlagsIncludeRegexp == nil || !collector.cpuFlagsIncludeRegexp.MatchString("foo") || collector.cpuFlagsIncludeRegexp.MatchString("bar") { + t.Fatalf("expected filtered runtime to keep initial cpu flags include regexp, got %v", collector.cpuFlagsIncludeRegexp) + } + + runtime2, err := NewRuntime(cfg, logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + collector2 := runtime2.collector.Collectors["cpu"].(*cpuCollector) + if !collector2.guestEnabled { + t.Fatal("expected new runtime to use updated cpu guest setting") + } + if collector2.cpuFlagsIncludeRegexp == nil || !collector2.cpuFlagsIncludeRegexp.MatchString("bar") || collector2.cpuFlagsIncludeRegexp.MatchString("foo") { + t.Fatalf("expected new runtime to use updated cpu flags include regexp, got %v", collector2.cpuFlagsIncludeRegexp) + } +} diff --git a/collector/runtime_textfile_test.go b/collector/runtime_textfile_test.go new file mode 100644 index 0000000000..709d391c0f --- /dev/null +++ b/collector/runtime_textfile_test.go @@ -0,0 +1,63 @@ +// 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. + +//go:build !notextfile + +package collector + +import ( + "io" + "log/slog" + "testing" + + "github.com/prometheus/node_exporter/config" +) + +func TestRuntimeSnapshotsTextfileCollectorConfig(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + originalDirectories := append([]string(nil), (*textFileDirectories)...) + t.Cleanup(func() { + *textFileDirectories = originalDirectories + }) + + *textFileDirectories = []string{"fixtures/textfile/first"} + cfg := config.NewConfigWithDefaults() + cfg.EnabledCollectors = []string{"textfile"} + + runtime, err := NewRuntime(cfg, logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + *textFileDirectories = []string{"fixtures/textfile/second"} + + filtered, err := runtime.Filtered("textfile") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + collector := filtered.collector.Collectors["textfile"].(*textFileCollector) + if got, want := collector.paths, []string{"fixtures/textfile/first"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("expected filtered runtime to keep initial textfile paths, got %v", got) + } + + runtime2, err := NewRuntime(cfg, logger) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + collector2 := runtime2.collector.Collectors["textfile"].(*textFileCollector) + if got, want := collector2.paths, []string{"fixtures/textfile/second"}; len(got) != len(want) || got[0] != want[0] { + t.Fatalf("expected new runtime to use updated textfile paths, got %v", got) + } +} diff --git a/collector/textfile.go b/collector/textfile.go index 17e4a60de2..07ece7a161 100644 --- a/collector/textfile.go +++ b/collector/textfile.go @@ -42,6 +42,10 @@ var ( ) ) +func newTextFileCollectorConfig() textFileCollectorConfig { + return textFileCollectorConfig{directories: append([]string(nil), (*textFileDirectories)...)} +} + type textFileCollector struct { paths []string // Only set for testing to get predictable output. @@ -57,12 +61,16 @@ func init() { // in the given textfile directory. func NewTextFileCollector(logger *slog.Logger) (Collector, error) { c := &textFileCollector{ - paths: *textFileDirectories, logger: logger, } return c, nil } +func (c *textFileCollector) configureRuntimeState(state *collectorRuntimeState) error { + c.paths = append([]string(nil), state.config.textfile.directories...) + return nil +} + func convertMetricFamily(metricFamily *dto.MetricFamily, ch chan<- prometheus.Metric, logger *slog.Logger) { var valType prometheus.ValueType var val float64 diff --git a/collector/textfile_config_stub.go b/collector/textfile_config_stub.go new file mode 100644 index 0000000000..869352f274 --- /dev/null +++ b/collector/textfile_config_stub.go @@ -0,0 +1,20 @@ +// 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. + +//go:build notextfile + +package collector + +func newTextFileCollectorConfig() textFileCollectorConfig { + return textFileCollectorConfig{} +} From ab7be3fc4caf089a873d84a3e8ca783f12a7da85 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Wed, 20 May 2026 11:41:57 +0100 Subject: [PATCH 6/6] collector: rename cpu config stub file --- collector/{cpu_config_other.go => cpu_config_stub.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename collector/{cpu_config_other.go => cpu_config_stub.go} (100%) diff --git a/collector/cpu_config_other.go b/collector/cpu_config_stub.go similarity index 100% rename from collector/cpu_config_other.go rename to collector/cpu_config_stub.go