diff --git a/README.md b/README.md index 66572cc9..8ded3a41 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ A Prometheus exporter for Windows machines. | [physical_disk](docs/collector.physical_disk.md) | physical disk metrics | ✓ | | [printer](docs/collector.printer.md) | Printer metrics | | | [process](docs/collector.process.md) | Per-process metrics | | +| [registry](docs/collector.registry.md) | Windows registry values (REG_DWORD and REG_QWORD) | | | [remote_fx](docs/collector.remote_fx.md) | RemoteFX protocol (RDP) metrics | | | [scheduled_task](docs/collector.scheduled_task.md) | Scheduled Tasks metrics | | | [service](docs/collector.service.md) | Service state metrics | ✓ | diff --git a/docs/collector.registry.md b/docs/collector.registry.md new file mode 100644 index 00000000..c3940db0 --- /dev/null +++ b/docs/collector.registry.md @@ -0,0 +1,279 @@ +# registry collector + +The registry collector exposes configured Windows registry values as named metrics. +Only values of type `REG_DWORD` and `REG_QWORD` are supported. + +Like the [performancecounter](collector.performancecounter.md) collector, each +registry value is mapped to its own metric: the key is a grouping container, and +every value under it declares the metric name, type, and labels it is exported as. + + + + | | | + |---------------------|------------------| + | Metric name prefix | `registry` | + | Data source | Windows Registry | + | Enabled by default? | No | + +## Flags + +### `--collector.registry.keys` + +Keys is a list of registry keys to collect values from. The value takes the form +of a JSON array of objects. YAML is supported. + +> [!CAUTION] +> If you are using a configuration file, the value must be kept as a string. +> +> Use a `|-` to keep the value as a string. + +#### Example + +```yaml +collector: + registry: + keys: |- + - name: windows_nt + key: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion + values: + - name: CurrentMajorVersionNumber + metric: windows_registry_windows_major_version + - name: memory_management + key: HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management + values: + - name: ClearPageFileAtShutdown +``` + +#### Schema + +YAML: + +```yaml +- name: windows_nt # required, unique id for the key group + key: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion + values: + - name: CurrentMajorVersionNumber # registry value name + metric: windows_registry_windows_major_version # optional + help: Windows major version number # optional + type: gauge # optional + labels: # optional + product: windows +- name: memory_management # required + key: HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management + values: + - name: ClearPageFileAtShutdown +``` + +JSON: + +```json +[ + { + "name": "windows_nt", + "key": "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", + "values": [ + { + "name": "CurrentMajorVersionNumber", + "metric": "windows_registry_windows_major_version", + "help": "Windows major version number", + "type": "gauge", + "labels": { "product": "windows" } + } + ] + }, + { + "name": "memory_management", + "key": "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management", + "values": [ + { "name": "ClearPageFileAtShutdown" } + ] + } +] +``` + +#### name + +Required, unique id for the key group. It is used as the `name` label on +`windows_registry_key_success`, to identify the key in logs, and to seed +auto-generated metric names (see [Metric naming](#metric-naming)). Each key must +have a distinct `name`; duplicates are rejected at build time. + +#### key + +The full path of the registry key, including the hive. Both short and long hive +names are accepted, case-insensitive, and forward slashes may be used instead of +backslashes: + +| Short | Long | +| --- | --- | +| `HKLM` | `HKEY_LOCAL_MACHINE` | +| `HKCU` | `HKEY_CURRENT_USER` | +| `HKU` | `HKEY_USERS` | +| `HKCR` | `HKEY_CLASSES_ROOT` | +| `HKCC` | `HKEY_CURRENT_CONFIG` | + +Each key must be unique after normalization (compared case-insensitively). +Subkeys are never recursed into. To collect values from a subkey, add it as a +separate entry. + +#### values + +The list of registry values to read from the key. At least one value must be +listed; the value names are matched case-insensitively. + +A value name listed more than once under one key (compared case-insensitively) is +rejected as a duplicate. If a value is missing at scrape time or is not a +`REG_DWORD`/`REG_QWORD`, the scrape reports a failure for that key via +`windows_registry_key_success`. + +#### values Sub-Schema + +##### name + +The name of the registry value to collect. Required. + +##### metric + +The name of the metric to expose. Optional — if omitted, a name is generated +automatically. See [Metric naming](#metric-naming) for the exact rules and +examples. + +The combination of metric name and labels must be unique across all configured +keys and values. Two values may deliberately share a `metric` name when their +labels differ (a common way to aggregate the same measurement from several keys). +A true duplicate — the same name *and* identical labels — is dropped and logged at +scrape time. + +##### help + +The metric `# HELP` text. Optional — if omitted, it defaults to +`windows_exporter: custom registry metric`. + +##### type + +The metric type. The value can be `gauge` or `counter`. If not specified, it +defaults to `gauge`, which suits the configuration-style values typically stored +in the registry. + +This key is optional. + +##### labels + +Labels is a map of key-value pairs that will be added as constant labels to the +metric. Two values may share the same `metric` name as long as their labels +distinguish the resulting series. + +This key is optional. + +## Metrics + +The registry collector returns one metric per configured value, named and typed +according to the configuration, plus a per-key success metric. + +| Name | Description | Type | Labels | +| --- | --- | --- | --- | +| *user defined* | Numeric value of a configured REG_DWORD or REG_QWORD value | gauge / counter | *user defined* | +| `windows_registry_key_success` | Whether the key could be opened and all of its configured values read (0, 1) | gauge | `name` | + +A `windows_registry_key_success` value of `0` means the key could not be opened +*or* at least one configured value was missing or not a `REG_DWORD`/`REG_QWORD`; +values that did read successfully are still exported. + +The `name` label on `windows_registry_key_success` is the configured key +[`name`](#name), used verbatim. Because each key must have a unique `name`, the +success metric can never emit duplicate series. + +### Metric naming + +Each value is exported under its own metric name. You can set it explicitly with +the `metric` field, or let the collector generate one. + +**Explicit name.** When `metric` is set, it is used verbatim. You are responsible +for following the Prometheus +[naming conventions](https://prometheus.io/docs/practices/naming/) (a `windows_` +prefix, and a unit suffix such as `_bytes` or `_seconds` where applicable): + +```yaml +values: + - name: CurrentMajorVersionNumber + metric: windows_registry_windows_major_version +``` + +→ `windows_registry_windows_major_version` + +**Auto-generated name.** When `metric` is omitted, the name is assembled as: + +``` +windows_registry__ +``` + +where `` is the key's [`name`](#name) and `` is the registry value +name. The whole string is then lowercased, every character that is not a letter or +digit is replaced with `_`, and leading/trailing `_` are trimmed. + +Example: + +```yaml +- name: windows_nt + key: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion + values: + - name: CurrentMajorVersionNumber +``` + +→ `windows_registry_windows_nt_currentmajorversionnumber` + +Each special character is replaced individually rather than collapsed, so a value +named `Foo (Bar)` becomes `foo__bar` (note the double underscore). Prefer an +explicit `metric` for readable names over awkward value names. + +> [!NOTE] +> The group `name` is not a namespace override: `name: cpu` produces +> `windows_registry_cpu_...`, not `windows_cpu_...`. Use an explicit `metric` to +> control the entire name. + +### Example metric + +For the example configuration above: + +``` +# HELP windows_registry_windows_major_version windows_exporter: custom registry metric +# TYPE windows_registry_windows_major_version gauge +windows_registry_windows_major_version{product="windows"} 10 +# HELP windows_registry_memory_management_clearpagefileatshutdown windows_exporter: custom registry metric +# TYPE windows_registry_memory_management_clearpagefileatshutdown gauge +windows_registry_memory_management_clearpagefileatshutdown 0 +# HELP windows_registry_key_success Whether the registry key could be read successfully. +# TYPE windows_registry_key_success gauge +windows_registry_key_success{name="windows_nt"} 1 +windows_registry_key_success{name="memory_management"} 1 +``` + +### Notes + +- `REG_DWORD` values are interpreted as unsigned 32-bit integers. Applications + that store negative numbers in a DWORD will surface as large positive values. +- `REG_QWORD` values larger than 2^53 lose precision when converted to the + 64-bit float used by Prometheus. + +## Useful queries + +Alert if a registry value deviates from the expected value: + +``` +windows_registry_memory_management_clearpagefileatshutdown != 0 +``` + +## Alerting examples + +**prometheus.rules** + +```yaml + - alert: RegistryKeyReadFailure + expr: windows_registry_key_success == 0 + for: 15m + labels: + severity: warning + annotations: + summary: "Registry key could not be read (instance {{ $labels.instance }})" + description: "The registry key {{ $labels.name }} could not be read for 15 minutes." +``` diff --git a/internal/collector/registry/registry.go b/internal/collector/registry/registry.go new file mode 100644 index 00000000..9ab136f9 --- /dev/null +++ b/internal/collector/registry/registry.go @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 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 windows + +package registry + +import ( + "errors" + "fmt" + "log/slog" + "regexp" + "slices" + "strings" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/types" + "github.com/prometheus/client_golang/prometheus" + "go.yaml.in/yaml/v3" + winregistry "golang.org/x/sys/windows/registry" +) + +const Name = "registry" + +var reNonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]`) + +type Config struct { + Keys []Key `yaml:"keys"` +} + +//nolint:gochecknoglobals +var ConfigDefaults = Config{ + Keys: make([]Key, 0), +} + +// A Collector is a Prometheus collector for Windows registry values. +type Collector struct { + config Config + + logger *slog.Logger + + keys []Key + + keySuccessDesc *prometheus.Desc +} + +func New(config *Config) *Collector { + if config == nil { + config = &ConfigDefaults + } + + if config.Keys == nil { + config.Keys = ConfigDefaults.Keys + } + + c := &Collector{ + config: *config, + } + + return c +} + +func NewWithFlags(app *kingpin.Application) *Collector { + c := &Collector{ + config: ConfigDefaults, + } + + var keys string + + app.Flag( + "collector.registry.keys", + "Registry keys to collect REG_DWORD and REG_QWORD values from. See docs for more information on how to use this flag. By default, no keys are collected.", + ).Default("").StringVar(&keys) + + app.Action(func(*kingpin.ParseContext) error { + if keys == "" { + return nil + } + + if err := yaml.Unmarshal([]byte(keys), &c.config.Keys); err != nil { + return fmt.Errorf("failed to parse keys %s: %w", keys, err) + } + + return nil + }) + + return c +} + +func (c *Collector) GetName() string { + return Name +} + +func (c *Collector) Close() error { + return nil +} + +func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + c.logger = logger.With(slog.String("collector", Name)) + + c.logger.Info("registry collector is in an experimental state! It may subject to change.") + + c.keySuccessDesc = prometheus.NewDesc( + prometheus.BuildFQName(types.Namespace, Name, "key_success"), + "Whether the registry key could be read successfully.", + []string{"name"}, + nil, + ) + + c.keys = make([]Key, 0, len(c.config.Keys)) + labels := make([]string, 0, len(c.config.Keys)) + groups := make([]string, 0, len(c.config.Keys)) + + var errs []error + + // seenMetricHelps and seenMetricTypes track the first-seen help text and + // value type for each metric name. Prometheus requires all metrics sharing + // a name to carry identical help text and type; mismatches cause silent + // metric drops at scrape time. Catching them here turns that into a loud + // build-time error. + seenMetricHelps := make(map[string]string) + seenMetricTypes := make(map[string]prometheus.ValueType) + + for _, key := range c.config.Keys { + if key.Key == "" { + errs = append(errs, errors.New("key is required")) + + continue + } + + hive, subPath, label, err := parseKeyPath(key.Key) + if err != nil { + errs = append(errs, err) + + continue + } + + if slices.Contains(labels, label) { + errs = append(errs, fmt.Errorf("key %s is duplicated", label)) + + continue + } + + labels = append(labels, label) + + if len(key.Values) == 0 { + errs = append(errs, fmt.Errorf("key %s has no values configured", label)) + + continue + } + + if key.Name == "" { + errs = append(errs, fmt.Errorf("name is required for key %s", label)) + + continue + } + + if slices.Contains(groups, key.Name) { + errs = append(errs, fmt.Errorf("key name %q is duplicated", key.Name)) + + continue + } + + groups = append(groups, key.Name) + + group := key.Name + + values := make([]Value, 0, len(key.Values)) + valueNames := make([]string, 0, len(key.Values)) + + for _, value := range key.Values { + if value.Name == "" { + errs = append(errs, fmt.Errorf("value name is required for key %s", label)) + + continue + } + + // Registry value names are matched case-insensitively, so lowercase + // them to keep reads consistent across systems. + value.Name = strings.ToLower(value.Name) + + if slices.Contains(valueNames, value.Name) { + errs = append(errs, fmt.Errorf("value name %q of key %s is duplicated", value.Name, label)) + + continue + } + + valueNames = append(valueNames, value.Name) + + // If no metric name is given, derive one from the key group and value + // name, mirroring the performancecounter collector. + if value.Metric == "" { + value.Metric = sanitizeMetricName( + fmt.Sprintf("%s_%s_%s_%s", types.Namespace, Name, group, value.Name), + ) + } + + switch value.Type { + case "", "gauge": + value.metricType = prometheus.GaugeValue + case "counter": + value.metricType = prometheus.CounterValue + default: + errs = append(errs, fmt.Errorf("value %q of key %s has invalid type %q, must be \"gauge\" or \"counter\"", value.Name, label, value.Type)) + + continue + } + + help := value.Help + if help == "" { + help = "windows_exporter: custom registry metric" + } + + if prevHelp, seen := seenMetricHelps[value.Metric]; seen { + if prevHelp != help { + errs = append(errs, fmt.Errorf( + "value %q of key %s: metric %q must have the same help text as other values sharing this metric name (got %q, want %q)", + value.Name, label, value.Metric, help, prevHelp, + )) + + continue + } + + if seenMetricTypes[value.Metric] != value.metricType { + errs = append(errs, fmt.Errorf( + "value %q of key %s: metric %q must have the same type as other values sharing this metric name", + value.Name, label, value.Metric, + )) + + continue + } + } else { + seenMetricHelps[value.Metric] = help + seenMetricTypes[value.Metric] = value.metricType + } + + value.desc = prometheus.NewDesc( + value.Metric, + help, + nil, + value.Labels, + ) + + values = append(values, value) + } + + // Build only resolves and validates the static configuration; it does not + // open the key. Whether a key can actually be opened and read depends on + // runtime conditions (existence, ACLs, transient locks) that are reported + // per key via the key_success metric at scrape time. Opening keys here + // would turn a recoverable, observable per-key condition into a fatal + // collector build error that aborts the whole exporter. + key.hive = hive + key.subPath = subPath + key.label = label + key.Values = values + + c.keys = append(c.keys, key) + } + + return errors.Join(errs...) +} + +// Collect sends the metric values for each configured registry key +// to the provided prometheus Metric channel. +func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { + var errs []error + + for _, key := range c.keys { + err := c.collectKey(ch, key) + success := 1.0 + + if err != nil { + errs = append(errs, err) + success = 0.0 + + c.logger.Warn("registry key collection failed", + slog.String("key", key.label), + slog.Any("err", err), + ) + } else { + c.logger.Debug("registry key collection succeeded", + slog.String("key", key.label), + ) + } + + ch <- prometheus.MustNewConstMetric( + c.keySuccessDesc, + prometheus.GaugeValue, + success, + key.Name, + ) + } + + return errors.Join(errs...) +} + +func (c *Collector) collectKey(ch chan<- prometheus.Metric, key Key) error { + rk, err := winregistry.OpenKey(key.hive, key.subPath, winregistry.QUERY_VALUE) + if err != nil { + return fmt.Errorf("failed to open registry key %s: %w", key.label, errors.Join(err, types.ErrNoData)) + } + + defer func() { + _ = rk.Close() + }() + + var errs []error + + for _, value := range key.Values { + val, _, err := rk.GetIntegerValue(value.Name) + if err != nil { + errs = append(errs, fmt.Errorf("failed to read value %q of registry key %s: %w", value.Name, key.label, errors.Join(err, types.ErrNoData))) + + continue + } + + ch <- prometheus.MustNewConstMetric( + value.desc, + value.metricType, + float64(val), + ) + } + + return errors.Join(errs...) +} + +// parseKeyPath splits a full registry key path into its hive and sub path, +// accepting short (HKLM) and long (HKEY_LOCAL_MACHINE) hive names as well as +// forward slashes as separators. The returned label is the normalized path +// with the short hive name and backslashes, lowercased so that the same key +// produces an identical label regardless of how it is cased in the registry or +// the configuration. The registry matches keys case-insensitively, so +// lowercasing the label can never collide with a distinct key. +func parseKeyPath(path string) (winregistry.Key, string, string, error) { + normalized := strings.Trim(strings.ReplaceAll(path, "/", `\`), `\`) + + hiveName, subPath, _ := strings.Cut(normalized, `\`) + + var ( + hive winregistry.Key + label string + ) + + switch strings.ToUpper(hiveName) { + case "HKLM", "HKEY_LOCAL_MACHINE": + hive, label = winregistry.LOCAL_MACHINE, "hklm" + case "HKCU", "HKEY_CURRENT_USER": + hive, label = winregistry.CURRENT_USER, "hkcu" + case "HKU", "HKEY_USERS": + hive, label = winregistry.USERS, "hku" + case "HKCR", "HKEY_CLASSES_ROOT": + hive, label = winregistry.CLASSES_ROOT, "hkcr" + case "HKCC", "HKEY_CURRENT_CONFIG": + hive, label = winregistry.CURRENT_CONFIG, "hkcc" + default: + return 0, "", "", fmt.Errorf("unknown registry hive %q in key %q", hiveName, path) + } + + if subPath != "" { + label += `\` + strings.ToLower(subPath) + } + + return hive, subPath, label, nil +} + +// sanitizeMetricName turns an arbitrary string into a valid Prometheus metric +// name by lowercasing it, replacing every non-alphanumeric character with an +// underscore, and trimming leading and trailing underscores. +func sanitizeMetricName(name string) string { + return strings.Trim(reNonAlphaNum.ReplaceAllString(strings.ToLower(name), "_"), "_") +} diff --git a/internal/collector/registry/registry_internal_test.go b/internal/collector/registry/registry_internal_test.go new file mode 100644 index 00000000..a89c7ccb --- /dev/null +++ b/internal/collector/registry/registry_internal_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 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 windows + +package registry + +import "testing" + +func TestParseKeyPathNormalization(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + wantLabel string + }{ + {"short hive lowercased", `HKLM\SOFTWARE\Foo`, `hklm\software\foo`}, + {"long hive normalized to short", `HKEY_LOCAL_MACHINE\SOFTWARE\Foo`, `hklm\software\foo`}, + {"forward slashes converted", `HKLM/SOFTWARE/Foo`, `hklm\software\foo`}, + {"mixed case sub path lowercased", `hklm\SoFtWaRe\BAR`, `hklm\software\bar`}, + {"surrounding slashes trimmed", `\HKLM\SOFTWARE\Foo\`, `hklm\software\foo`}, + {"hive only", `HKCU`, `hkcu`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, label, err := parseKeyPath(tc.input) + if err != nil { + t.Fatalf("parseKeyPath(%q) returned error: %v", tc.input, err) + } + + if label != tc.wantLabel { + t.Errorf("parseKeyPath(%q) label = %q, want %q", tc.input, label, tc.wantLabel) + } + }) + } +} + +func TestSanitizeMetricName(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + input string + want string + }{ + {"already valid", "windows_registry_foo", "windows_registry_foo"}, + {"spaces and case", "Windows NT Version", "windows_nt_version"}, + {"backslashes from key path", `windows_registry_hklm\software\foo`, "windows_registry_hklm_software_foo"}, + {"surrounding non-alphanumerics trimmed", "%value%", "value"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := sanitizeMetricName(tc.input); got != tc.want { + t.Errorf("sanitizeMetricName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} diff --git a/internal/collector/registry/registry_output_test.go b/internal/collector/registry/registry_output_test.go new file mode 100644 index 00000000..d64c05b9 --- /dev/null +++ b/internal/collector/registry/registry_output_test.go @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 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 windows + +package registry_test + +import ( + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/prometheus-community/windows_exporter/internal/collector/registry" + "github.com/prometheus-community/windows_exporter/internal/types" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/stretchr/testify/require" + winregistry "golang.org/x/sys/windows/registry" +) + +// collectorAdapter bridges registry.Collector's Collect(ch, time.Duration) error +// signature to the prometheus.Collector interface, mirroring the adapter in the +// performancecounter collector's tests. +type collectorAdapter struct { + registry.Collector +} + +// Describe implements the prometheus.Collector interface. +func (collectorAdapter) Describe(chan<- *prometheus.Desc) {} + +// Collect implements the prometheus.Collector interface. +// +// Unlike the performancecounter adapter, the Collect error is intentionally +// discarded rather than panicked on: a missing key is an expected, non-fatal +// condition reported via key_success=0, and this test deliberately configures a +// bogus key, so Collect returns a non-nil joined error while still emitting +// every metric. +func (a collectorAdapter) Collect(ch chan<- prometheus.Metric) { + _ = a.Collector.Collect(ch, 0) +} + +// TestCollectorMetrics asserts the full value→Desc→metric wiring end to end by +// reading known REG_DWORD/REG_QWORD values from a throwaway HKCU key. It exercises +// the auto-generated metric name, an explicit metric: override, gauge vs counter +// types, constant labels, exact 32- and 64-bit values, and the key_success 1-vs-0 +// semantics (a missing key fails just that key, not the whole scrape). Because the +// test controls the input values, it asserts exact values rather than the numeric +// patterns the performancecounter test must use. Using HKCU keeps it admin-free +// and reliable in CI. +func TestCollectorMetrics(t *testing.T) { + t.Parallel() + + suffix := time.Now().UnixNano() + + sub := fmt.Sprintf(`Software\windows_exporter_registry_test_%d`, suffix) + keyPath := `HKCU\` + sub + + k, _, err := winregistry.CreateKey(winregistry.CURRENT_USER, sub, winregistry.SET_VALUE) + require.NoError(t, err) + + // REG_DWORD fits in 32 bits; REG_QWORD is deliberately > math.MaxUint32 to prove + // 64-bit handling. Both are well under 2^53, so they convert to float64 exactly. + require.NoError(t, k.SetDWordValue("TestDword", 1234)) + require.NoError(t, k.SetQWordValue("TestQword", 5_000_000_000)) + require.NoError(t, k.Close()) + + t.Cleanup(func() { + _ = winregistry.DeleteKey(winregistry.CURRENT_USER, sub) + }) + + // A second key that does not exist exercises graceful per-key failure: the + // collector reports key_success=0 for it instead of aborting the scrape. + missingSub := fmt.Sprintf(`Software\windows_exporter_missing_%d`, suffix) + missingPath := `HKCU\` + missingSub + + c := registry.New(®istry.Config{ + Keys: []registry.Key{ + { + Name: "testgroup", + Key: keyPath, + Values: []registry.Value{ + // Auto-named gauge → windows_registry_testgroup_testdword. + {Name: "TestDword"}, + // Explicit metric: override + counter type + constant label. + { + Name: "TestQword", + Metric: "windows_registry_test_custom_metric", + Help: "custom registry help text", + Type: "counter", + Labels: map[string]string{"foo": "bar"}, + }, + }, + }, + { + Name: "missing_group", + Key: missingPath, + Values: []registry.Value{{Name: "AnyValue"}}, + }, + }, + }) + + require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil)) + + reg := prometheus.NewRegistry() + reg.MustRegister(collectorAdapter{*c}) + + rw := httptest.NewRecorder() + promhttp.HandlerFor(reg, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}).ServeHTTP(rw, &http.Request{}) + got := rw.Body.String() + + require.NotEmpty(t, got) + + // key_success labels are the configured key name (not the path). + // Metric families are emitted sorted by name, and metrics within the + // key_success family sorted by label value, so "missing_group" (0) + // precedes "testgroup" (1). + expected := `# HELP windows_registry_key_success Whether the registry key could be read successfully. +# TYPE windows_registry_key_success gauge +windows_registry_key_success{name="missing_group"} 0 +windows_registry_key_success{name="testgroup"} 1 +# HELP windows_registry_test_custom_metric custom registry help text +# TYPE windows_registry_test_custom_metric counter +windows_registry_test_custom_metric{foo="bar"} 5e+09 +# HELP windows_registry_testgroup_testdword windows_exporter: custom registry metric +# TYPE windows_registry_testgroup_testdword gauge +windows_registry_testgroup_testdword 1234 +` + + // QuoteMeta escapes the backslashes/braces in the exposition so the exact + // values, types, labels, and names are matched literally. + require.Regexp(t, "^"+regexp.QuoteMeta(expected), got) +} + +// TestCollectMissingKeyReturnsErrNoData verifies that a key that does not exist +// returns an error wrapping types.ErrNoData, so the framework marks the +// collector "succeeded with warnings" rather than failed. +func TestCollectMissingKeyReturnsErrNoData(t *testing.T) { + t.Parallel() + + c := registry.New(®istry.Config{ + Keys: []registry.Key{ + { + Name: "missing", + Key: fmt.Sprintf(`HKCU\Software\windows_exporter_does_not_exist_%d`, time.Now().UnixNano()), + Values: []registry.Value{{Name: "AnyValue"}}, + }, + }, + }) + + require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil)) + + ch := make(chan prometheus.Metric, 10) + err := c.Collect(ch, 0) + close(ch) + + require.Error(t, err) + require.ErrorIs(t, err, types.ErrNoData) +} + +// TestCollectMissingValueReturnsErrNoData verifies that a value that does not +// exist within an otherwise-openable key also returns types.ErrNoData. +func TestCollectMissingValueReturnsErrNoData(t *testing.T) { + t.Parallel() + + sub := fmt.Sprintf(`Software\windows_exporter_registry_missing_val_%d`, time.Now().UnixNano()) + + k, _, err := winregistry.CreateKey(winregistry.CURRENT_USER, sub, winregistry.SET_VALUE) + require.NoError(t, err) + require.NoError(t, k.Close()) + + t.Cleanup(func() { _ = winregistry.DeleteKey(winregistry.CURRENT_USER, sub) }) + + c := registry.New(®istry.Config{ + Keys: []registry.Key{ + { + Name: "test", + Key: `HKCU\` + sub, + Values: []registry.Value{{Name: "DoesNotExist"}}, + }, + }, + }) + + require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil)) + + ch := make(chan prometheus.Metric, 10) + err = c.Collect(ch, 0) + close(ch) + + require.Error(t, err) + require.ErrorIs(t, err, types.ErrNoData) +} + +// TestCollectorSharedMetricName proves the supported aggregation pattern from the +// docs: two values may share one metric name as long as their labels differ. The +// collector deliberately does not reject this; only a true duplicate (same name and +// identical labels) would be dropped and logged by the registry at scrape time. +func TestCollectorSharedMetricName(t *testing.T) { + t.Parallel() + + sub := fmt.Sprintf(`Software\windows_exporter_registry_shared_%d`, time.Now().UnixNano()) + + k, _, err := winregistry.CreateKey(winregistry.CURRENT_USER, sub, winregistry.SET_VALUE) + require.NoError(t, err) + + require.NoError(t, k.SetDWordValue("Alpha", 1)) + require.NoError(t, k.SetDWordValue("Beta", 2)) + require.NoError(t, k.Close()) + + t.Cleanup(func() { + _ = winregistry.DeleteKey(winregistry.CURRENT_USER, sub) + }) + + c := registry.New(®istry.Config{ + Keys: []registry.Key{ + { + Name: "shared_test", + Key: `HKCU\` + sub, + Values: []registry.Value{ + {Name: "Alpha", Metric: "windows_registry_shared", Labels: map[string]string{"slot": "a"}}, + {Name: "Beta", Metric: "windows_registry_shared", Labels: map[string]string{"slot": "b"}}, + }, + }, + }, + }) + + require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil)) + + reg := prometheus.NewRegistry() + reg.MustRegister(collectorAdapter{*c}) + + rw := httptest.NewRecorder() + promhttp.HandlerFor(reg, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}).ServeHTTP(rw, &http.Request{}) + got := rw.Body.String() + + // Both series share the metric name and differ only by label, so both are + // exported rather than one being dropped as a duplicate. + require.Contains(t, got, `windows_registry_shared{slot="a"} 1`) + require.Contains(t, got, `windows_registry_shared{slot="b"} 2`) +} diff --git a/internal/collector/registry/registry_test.go b/internal/collector/registry/registry_test.go new file mode 100644 index 00000000..5c416044 --- /dev/null +++ b/internal/collector/registry/registry_test.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 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 windows + +package registry_test + +import ( + "log/slog" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus-community/windows_exporter/internal/collector/registry" + "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/stretchr/testify/require" +) + +func BenchmarkCollector(b *testing.B) { + keys := `[{"name":"windows_nt","key":"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion","values":[{"name":"CurrentMajorVersionNumber"}]}]` + + testutils.FuncBenchmarkCollector(b, registry.Name, registry.NewWithFlags, func(app *kingpin.Application) { + app.GetFlag("collector.registry.keys").StringVar(&keys) + }) +} + +func TestCollector(t *testing.T) { + t.Parallel() + + testutils.TestCollector(t, registry.New, ®istry.Config{ + Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: "CurrentMajorVersionNumber"}}, + }, + { + Name: "memory_management", + Key: `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management`, + Values: []registry.Value{{Name: "ClearPageFileAtShutdown", Type: "gauge"}}, + }, + }, + }) +} + +func TestCollectorBuildErrors(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + config registry.Config + }{ + { + name: "unknown hive", + config: registry.Config{Keys: []registry.Key{{Key: `BOGUS\Foo`}}}, + }, + { + name: "empty key", + config: registry.Config{Keys: []registry.Key{{Key: ""}}}, + }, + { + name: "key with no values", + config: registry.Config{Keys: []registry.Key{ + {Name: "windows_nt", Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`}, + }}, + }, + { + name: "duplicate key", + config: registry.Config{Keys: []registry.Key{ + {Name: "nt_a", Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`}, + {Name: "nt_b", Key: `HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Windows NT/CurrentVersion`}, + }}, + }, + { + name: "duplicate key differing only by case", + config: registry.Config{Keys: []registry.Key{ + {Name: "nt_a", Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`}, + {Name: "nt_b", Key: `hklm\software\microsoft\windows nt\currentversion`}, + }}, + }, + { + name: "duplicate value differing only by case", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: "CurrentMajorVersionNumber"}, {Name: "currentmajorversionnumber"}}, + }, + }}, + }, + { + name: "missing value name", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: ""}}, + }, + }}, + }, + { + name: "invalid value type", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: "CurrentMajorVersionNumber", Type: "histogram"}}, + }, + }}, + }, + { + name: "shared metric name with inconsistent help text", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{ + {Name: "CurrentMajorVersionNumber", Metric: "windows_version", Help: "version help a"}, + {Name: "CurrentMinorVersionNumber", Metric: "windows_version", Help: "version help b"}, + }, + }, + }}, + }, + { + name: "shared metric name with inconsistent type", + config: registry.Config{Keys: []registry.Key{ + { + Name: "windows_nt", + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{ + {Name: "CurrentMajorVersionNumber", Metric: "windows_version", Type: "gauge"}, + {Name: "CurrentMinorVersionNumber", Metric: "windows_version", Type: "counter"}, + }, + }, + }}, + }, + { + name: "missing name", + config: registry.Config{Keys: []registry.Key{ + { + Key: `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`, + Values: []registry.Value{{Name: "CurrentMajorVersionNumber"}}, + }, + }}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + c := registry.New(&tc.config) + require.Error(t, c.Build(slog.New(slog.DiscardHandler), nil)) + }) + } +} diff --git a/internal/collector/registry/types.go b/internal/collector/registry/types.go new file mode 100644 index 00000000..39e92602 --- /dev/null +++ b/internal/collector/registry/types.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright 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 windows + +package registry + +import ( + "github.com/prometheus/client_golang/prometheus" + "go.yaml.in/yaml/v3" + winregistry "golang.org/x/sys/windows/registry" +) + +type Key struct { + Name string `json:"name" yaml:"name"` + Key string `json:"key" yaml:"key"` + Values []Value `json:"values" yaml:"values"` + + // resolved at Build time + hive winregistry.Key + subPath string + label string +} + +type Value struct { + Name string `json:"name" yaml:"name"` + Metric string `json:"metric" yaml:"metric"` + Help string `json:"help" yaml:"help"` + Type string `json:"type" yaml:"type"` + Labels map[string]string `json:"labels" yaml:"labels"` + + // resolved at Build time + desc *prometheus.Desc + metricType prometheus.ValueType +} + +// UnmarshalYAML is a no-op, so the strict config file validation accepts the +// keys being provided as a string. See the performancecounter collector. +func (*Config) UnmarshalYAML(*yaml.Node) error { + return nil +} diff --git a/pkg/collector/collection.go b/pkg/collector/collection.go index 277177b2..37076217 100644 --- a/pkg/collector/collection.go +++ b/pkg/collector/collection.go @@ -60,6 +60,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/physical_disk" "github.com/prometheus-community/windows_exporter/internal/collector/printer" "github.com/prometheus-community/windows_exporter/internal/collector/process" + "github.com/prometheus-community/windows_exporter/internal/collector/registry" "github.com/prometheus-community/windows_exporter/internal/collector/remote_fx" "github.com/prometheus-community/windows_exporter/internal/collector/scheduled_task" "github.com/prometheus-community/windows_exporter/internal/collector/service" @@ -79,7 +80,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/pdh" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus/client_golang/prometheus" - "golang.org/x/sys/windows/registry" + winregistry "golang.org/x/sys/windows/registry" ) // NewWithFlags To be called by the exporter for collector initialization before running kingpin.Parse. @@ -130,6 +131,7 @@ func NewWithConfig(config Config) *Collection { collectors[physical_disk.Name] = physical_disk.New(&config.PhysicalDisk) collectors[printer.Name] = printer.New(&config.Printer) collectors[process.Name] = process.New(&config.Process) + collectors[registry.Name] = registry.New(&config.Registry) collectors[remote_fx.Name] = remote_fx.New(&config.RemoteFx) collectors[scheduled_task.Name] = scheduled_task.New(&config.ScheduledTask) collectors[service.Name] = service.New(&config.Service) @@ -241,7 +243,7 @@ func (c *Collection) Build(ctx context.Context, logger *slog.Logger) error { for err := range errCh { if errors.Is(err, pdh.ErrNoData) || - errors.Is(err, registry.ErrNotExist) || + errors.Is(err, winregistry.ErrNotExist) || errors.Is(err, pdh.NewPdhError(pdh.CstatusNoObject)) || errors.Is(err, pdh.NewPdhError(pdh.CstatusNoCounter)) || errors.Is(err, mi.MI_RESULT_INVALID_OPERATION_TIMEOUT) || diff --git a/pkg/collector/config.go b/pkg/collector/config.go index 691300cd..e5a26b7d 100644 --- a/pkg/collector/config.go +++ b/pkg/collector/config.go @@ -50,6 +50,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/physical_disk" "github.com/prometheus-community/windows_exporter/internal/collector/printer" "github.com/prometheus-community/windows_exporter/internal/collector/process" + "github.com/prometheus-community/windows_exporter/internal/collector/registry" "github.com/prometheus-community/windows_exporter/internal/collector/remote_fx" "github.com/prometheus-community/windows_exporter/internal/collector/scheduled_task" "github.com/prometheus-community/windows_exporter/internal/collector/service" @@ -100,6 +101,7 @@ type Config struct { PhysicalDisk physical_disk.Config `yaml:"physical_disk"` Printer printer.Config `yaml:"printer"` Process process.Config `yaml:"process"` + Registry registry.Config `yaml:"registry"` RemoteFx remote_fx.Config `yaml:"remote_fx"` ScheduledTask scheduled_task.Config `yaml:"scheduled_task"` Service service.Config `yaml:"service"` @@ -154,6 +156,7 @@ var ConfigDefaults = Config{ PhysicalDisk: physical_disk.ConfigDefaults, Printer: printer.ConfigDefaults, Process: process.ConfigDefaults, + Registry: registry.ConfigDefaults, RemoteFx: remote_fx.ConfigDefaults, ScheduledTask: scheduled_task.ConfigDefaults, Service: service.ConfigDefaults, diff --git a/pkg/collector/map.go b/pkg/collector/map.go index 2c5aa382..c18cc394 100644 --- a/pkg/collector/map.go +++ b/pkg/collector/map.go @@ -54,6 +54,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/physical_disk" "github.com/prometheus-community/windows_exporter/internal/collector/printer" "github.com/prometheus-community/windows_exporter/internal/collector/process" + "github.com/prometheus-community/windows_exporter/internal/collector/registry" "github.com/prometheus-community/windows_exporter/internal/collector/remote_fx" "github.com/prometheus-community/windows_exporter/internal/collector/scheduled_task" "github.com/prometheus-community/windows_exporter/internal/collector/service" @@ -111,6 +112,7 @@ var BuildersWithFlags = map[string]BuilderWithFlags[Collector]{ physical_disk.Name: NewBuilderWithFlags(physical_disk.NewWithFlags), printer.Name: NewBuilderWithFlags(printer.NewWithFlags), process.Name: NewBuilderWithFlags(process.NewWithFlags), + registry.Name: NewBuilderWithFlags(registry.NewWithFlags), remote_fx.Name: NewBuilderWithFlags(remote_fx.NewWithFlags), scheduled_task.Name: NewBuilderWithFlags(scheduled_task.NewWithFlags), service.Name: NewBuilderWithFlags(service.NewWithFlags),