diff --git a/collector/collector.go b/collector/collector.go index 18ff7388ca..fe94cd4638 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -49,13 +49,46 @@ 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 { + config collectorConfig + enabled map[string]bool + collectors map[string]Collector + mtx sync.Mutex +} + +type runtimeConfiguredCollector interface { + 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)) { var helpDefaultState string if isDefaultEnabled { @@ -80,14 +113,30 @@ 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{ + 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 @@ -103,39 +152,50 @@ 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 } + if err := s.configureCollector(collector); err != nil { + return nil, err + } collectors[key] = collector - initiatedCollectors[key] = collector + s.collectors[key] = collector } } return &NodeCollector{Collectors: collectors, logger: logger}, nil } +func (s *collectorRuntimeState) configureCollector(collector Collector) error { + if configuredCollector, ok := collector.(runtimeConfiguredCollector); ok { + return configuredCollector.configureRuntimeState(s) + } + + return nil +} + // Describe implements the prometheus.Collector interface. func (n NodeCollector) Describe(ch chan<- *prometheus.Desc) { ch <- scrapeDurationDesc diff --git a/collector/cpu_config_stub.go b/collector/cpu_config_stub.go new file mode 100644 index 0000000000..41451e988a --- /dev/null +++ b/collector/cpu_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 !linux || nocpu + +package collector + +func newCPUCollectorConfig() cpuCollectorConfig { + return cpuCollectorConfig{} +} diff --git a/collector/cpu_linux.go b/collector/cpu_linux.go index 30fe305ac0..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 @@ -50,6 +51,9 @@ type cpuCollector struct { cpuStats map[int64]procfs.CPUStat cpuStatsMutex sync.Mutex isolatedCpus []uint16 + cpuFreqEnabled bool + guestEnabled bool + infoEnabled bool cpuFlagsIncludeRegexp *regexp.Regexp cpuBugsIncludeRegexp *regexp.Regexp @@ -66,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.", @@ -139,32 +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), + logger: logger, + cpuStats: make(map[int64]procfs.CPUStat), } - err = c.compileIncludeFlags(flagsInclude, bugsInclude) + return c, nil +} + +func (c *cpuCollector) configureRuntimeState(state *collectorRuntimeState) error { + pfs, err := procfs.NewFS(state.config.paths.procMountPoint) 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) + return fmt.Errorf("failed to open procfs: %w", err) } - return c, nil + + 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 } @@ -174,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 } @@ -219,10 +241,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, @@ -266,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 } @@ -395,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.go b/collector/runtime.go new file mode 100644 index 0000000000..2b5bbd6d9e --- /dev/null +++ b/collector/runtime.go @@ -0,0 +1,82 @@ +// 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 { + state *collectorRuntimeState + collector *NodeCollector + logger *slog.Logger +} + +func NewRuntime(cfg config.Config, logger *slog.Logger) (*Runtime, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + 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 + } + + 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 filtered, 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_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_test.go b/collector/runtime_test.go new file mode 100644 index 0000000000..8616ed5ba1 --- /dev/null +++ b/collector/runtime_test.go @@ -0,0 +1,119 @@ +// 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") + } +} + +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/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{} +} 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..4a8e07c5a5 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,30 @@ 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 { + r, err := h.runtime.Filtered(filters...) + 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 +195,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 +203,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 +222,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", }, },