wmi: add generic wmi collector (#2530)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-07 23:22:11 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a67a64c55c
commit 88034b818e
16 changed files with 2309 additions and 32 deletions
+62
View File
@@ -0,0 +1,62 @@
// 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 wmi
import (
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus/client_golang/prometheus"
"go.yaml.in/yaml/v3"
)
type Query struct {
Name string `json:"name" yaml:"name"`
Namespace string `json:"namespace" yaml:"namespace"`
Class string `json:"class" yaml:"class"`
Where string `json:"where" yaml:"where"`
LabelProperties []LabelProperty `json:"label_properties" yaml:"label_properties"`
Properties []Property `json:"properties" yaml:"properties"`
// Resolved at Build time.
namespace mi.Namespace
query mi.Query
wql string
labelNames []string
}
type LabelProperty struct {
Name string `json:"name" yaml:"name"`
Label string `json:"label" yaml:"label"`
}
type Property 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
// queries being provided as a string. See the performancecounter collector.
func (*Config) UnmarshalYAML(*yaml.Node) error {
return nil
}
+604
View File
@@ -0,0 +1,604 @@
// 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 wmi
import (
"errors"
"fmt"
"log/slog"
"maps"
"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"
)
const (
Name = "wmi"
defaultNamespace = "root/CIMv2"
// minQueryTimeout is the least time left in the scrape for which a query
// is still started.
minQueryTimeout = time.Millisecond
)
var errScrapeTimeout = errors.New("scrape timeout exceeded")
var (
reNonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]`)
// reIdentifier matches WQL class and property names. They are interpolated
// into the generated SELECT statement, so anything else is rejected.
reIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
)
type Config struct {
Queries []Query `yaml:"queries"`
}
//nolint:gochecknoglobals
var ConfigDefaults = Config{
Queries: make([]Query, 0),
}
// A Collector is a Prometheus collector for user-defined WMI queries.
type Collector struct {
config Config
logger *slog.Logger
miSession *mi.Session
queries []Query
querySuccessDesc *prometheus.Desc
queryDurationDesc *prometheus.Desc
}
// metricSignature is what every property sharing a metric name must agree
// on. Prometheus rejects a metric family whose members differ in help text,
// type or label names.
type metricSignature struct {
help string
metricType prometheus.ValueType
labelNames string
}
func New(config *Config) *Collector {
if config == nil {
config = &ConfigDefaults
}
if config.Queries == nil {
config.Queries = ConfigDefaults.Queries
}
c := &Collector{
config: *config,
}
return c
}
func NewWithFlags(app *kingpin.Application) *Collector {
c := &Collector{
config: ConfigDefaults,
}
var queries string
app.Flag(
"collector.wmi.queries",
"WMI queries to collect numeric properties from. See docs for more information on how to use this flag. By default, no queries are run.",
).Default("").StringVar(&queries)
app.Action(func(*kingpin.ParseContext) error {
if queries == "" {
return nil
}
if err := yaml.Unmarshal([]byte(queries), &c.config.Queries); err != nil {
return fmt.Errorf("failed to parse queries %s: %w", queries, err)
}
return nil
})
return c
}
func (c *Collector) GetName() string {
return Name
}
func (c *Collector) Close() error {
return nil
}
//nolint:gocognit,cyclop,funlen,maintidx // validation of the user configuration is a flat list of checks
func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
c.logger = logger.With(slog.String("collector", Name))
c.logger.Info("wmi collector is in an experimental state! It may subject to change.")
c.querySuccessDesc = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "query_success"),
"Whether the WMI query and all of its configured properties could be read successfully.",
[]string{"name"},
nil,
)
c.queryDurationDesc = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "query_duration_seconds"),
"Duration of the WMI query.",
[]string{"name"},
nil,
)
c.queries = make([]Query, 0, len(c.config.Queries))
if len(c.config.Queries) == 0 {
return nil
}
if miSession == nil {
return errors.New("miSession is nil")
}
c.miSession = miSession
// names maps the sanitized query name, which seeds auto-generated metric
// names, to the configured one. Names that only differ in case or special
// characters would produce the same metrics.
names := make(map[string]string, len(c.config.Queries))
signatures := make(map[string]metricSignature)
// seriesQueries maps the series of queries without label properties to
// the query producing it. Such a query emits exactly one series per
// property, so a collision with another query is certain. Series of
// queries with label properties depend on the instances returned at
// runtime and cannot be checked here.
seriesQueries := make(map[string]string)
reservedMetrics := []string{
prometheus.BuildFQName(types.Namespace, Name, "query_success"),
prometheus.BuildFQName(types.Namespace, Name, "query_duration_seconds"),
}
var errs []error
for _, query := range c.config.Queries {
if query.Name == "" {
errs = append(errs, errors.New("query name is required"))
continue
}
if prev, ok := names[sanitizeMetricName(query.Name)]; ok {
if prev == query.Name {
errs = append(errs, fmt.Errorf("query %s: name is duplicated", query.Name))
} else {
errs = append(errs, fmt.Errorf("query %s: name produces the same metric names as query %s", query.Name, prev))
}
continue
}
names[sanitizeMetricName(query.Name)] = query.Name
if !reIdentifier.MatchString(query.Class) {
errs = append(errs, fmt.Errorf("query %s: class %q must be a valid WMI class name", query.Name, query.Class))
continue
}
if len(query.Properties) == 0 {
errs = append(errs, fmt.Errorf("query %s: no properties configured", query.Name))
continue
}
if query.Namespace == "" {
query.Namespace = defaultNamespace
}
// MI expects forward slashes, but backslashes are the common notation
// in WMI tooling.
query.Namespace = strings.ReplaceAll(query.Namespace, `\`, "/")
var err error
query.namespace, err = mi.NewNamespace(query.Namespace)
if err != nil {
errs = append(errs, fmt.Errorf("query %s: invalid namespace %q: %w", query.Name, query.Namespace, err))
continue
}
// selected collects the property names for the SELECT statement. WMI
// property names are case-insensitive, so they are compared lowercased.
selected := make([]string, 0, len(query.LabelProperties)+len(query.Properties))
selectedLower := make([]string, 0, cap(selected))
addSelected := func(name string) {
if !slices.Contains(selectedLower, strings.ToLower(name)) {
selected = append(selected, name)
selectedLower = append(selectedLower, strings.ToLower(name))
}
}
queryErrs := len(errs)
labelProperties := make([]LabelProperty, 0, len(query.LabelProperties))
query.labelNames = make([]string, 0, len(query.LabelProperties))
for _, labelProperty := range query.LabelProperties {
if !reIdentifier.MatchString(labelProperty.Name) {
errs = append(errs, fmt.Errorf("query %s: label property %q must be a valid WMI property name", query.Name, labelProperty.Name))
continue
}
if labelProperty.Label == "" {
labelProperty.Label = sanitizeMetricName(labelProperty.Name)
}
if slices.Contains(query.labelNames, labelProperty.Label) {
errs = append(errs, fmt.Errorf("query %s: label %q is duplicated", query.Name, labelProperty.Label))
continue
}
query.labelNames = append(query.labelNames, labelProperty.Label)
labelProperties = append(labelProperties, labelProperty)
addSelected(labelProperty.Name)
}
properties := make([]Property, 0, len(query.Properties))
propertyNames := make([]string, 0, len(query.Properties))
for _, property := range query.Properties {
if !reIdentifier.MatchString(property.Name) {
errs = append(errs, fmt.Errorf("query %s: property %q must be a valid WMI property name", query.Name, property.Name))
continue
}
if slices.Contains(propertyNames, strings.ToLower(property.Name)) {
errs = append(errs, fmt.Errorf("query %s: property %q is duplicated", query.Name, property.Name))
continue
}
propertyNames = append(propertyNames, strings.ToLower(property.Name))
// If no metric name is given, derive one from the query name and
// property name, mirroring the registry collector.
if property.Metric == "" {
property.Metric = sanitizeMetricName(
fmt.Sprintf("%s_%s_%s_%s", types.Namespace, Name, query.Name, property.Name),
)
}
if slices.Contains(reservedMetrics, property.Metric) {
errs = append(errs, fmt.Errorf("query %s: property %q: metric %q is reserved by the wmi collector", query.Name, property.Name, property.Metric))
continue
}
switch property.Type {
case "", "gauge":
property.metricType = prometheus.GaugeValue
case "counter":
property.metricType = prometheus.CounterValue
default:
errs = append(errs, fmt.Errorf("query %s: property %q has invalid type %q, must be \"gauge\" or \"counter\"", query.Name, property.Name, property.Type))
continue
}
if label, ok := firstCommonLabel(query.labelNames, property.Labels); ok {
errs = append(errs, fmt.Errorf("query %s: property %q: constant label %q is already set by a label property", query.Name, property.Name, label))
continue
}
if k := slices.IndexFunc(properties, func(other Property) bool {
return other.Metric == property.Metric && maps.Equal(other.Labels, property.Labels)
}); k != -1 {
errs = append(errs, fmt.Errorf("query %s: properties %q and %q produce identical series %s, set a different metric name or labels",
query.Name, properties[k].Name, property.Name, property.Metric,
))
continue
}
series := seriesKey(property.Metric, property.Labels)
if prev, ok := seriesQueries[series]; ok && len(query.labelNames) == 0 {
errs = append(errs, fmt.Errorf("query %s: property %q produces the same series %s as query %s, set a different metric name or labels",
query.Name, property.Name, property.Metric, prev,
))
continue
}
if property.Help == "" {
property.Help = "windows_exporter: custom WMI metric"
}
labelNames := append(slices.Collect(maps.Keys(property.Labels)), query.labelNames...)
slices.Sort(labelNames)
signature := metricSignature{
help: property.Help,
metricType: property.metricType,
labelNames: strings.Join(labelNames, ","),
}
if prev, seen := signatures[property.Metric]; seen && prev != signature {
errs = append(errs, fmt.Errorf(
"query %s: property %q: metric %q must have the same help text, type and label names as other properties sharing this metric name",
query.Name, property.Name, property.Metric,
))
continue
}
property.desc = prometheus.NewDesc(
property.Metric,
property.Help,
query.labelNames,
property.Labels,
)
// Creating a sample metric surfaces invalid metric or label names,
// such as reserved "__" labels, now instead of panicking at scrape
// time.
if _, err := prometheus.NewConstMetric(property.desc, property.metricType, 0, make([]string, len(query.labelNames))...); err != nil {
errs = append(errs, fmt.Errorf("query %s: property %q: %w", query.Name, property.Name, err))
continue
}
signatures[property.Metric] = signature
if len(query.labelNames) == 0 {
seriesQueries[series] = query.Name
}
properties = append(properties, property)
addSelected(property.Name)
}
if len(errs) > queryErrs {
continue
}
query.LabelProperties = labelProperties
query.Properties = properties
query.Where = strings.TrimSpace(query.Where)
query.wql = fmt.Sprintf("SELECT %s FROM %s", strings.Join(selected, ", "), query.Class)
if query.Where != "" {
query.wql += " WHERE " + query.Where
}
query.query, err = mi.NewQuery(query.wql)
if err != nil {
errs = append(errs, fmt.Errorf("query %s: invalid query %q: %w", query.Name, query.wql, err))
continue
}
// Build only validates the static configuration and does not run the
// query. Whether a namespace, class or property exists and is readable
// is a runtime condition, reported per query via query_success.
c.queries = append(c.queries, query)
}
return errors.Join(errs...)
}
// Collect sends the metric values for each configured WMI query
// to the provided prometheus Metric channel.
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
// The queries run one after another and share the scrape timeout, so each
// query only gets the time that is left. A non-positive timeout means no
// deadline.
var deadline time.Time
if maxScrapeDuration > 0 {
deadline = time.Now().Add(maxScrapeDuration)
}
var errs []error
for _, query := range c.queries {
startTime := time.Now()
err := c.collectQuery(ch, query, deadline)
duration := time.Since(startTime)
success := 1.0
if err != nil {
// A failing query is reported via query_success. Like the registry
// collector, it does not fail the whole collector.
errs = append(errs, fmt.Errorf("failed to collect query %s: %w", query.Name, errors.Join(err, types.ErrNoData)))
success = 0.0
c.logger.Warn("wmi query failed",
slog.String("query", query.Name),
slog.String("wql", query.wql),
slog.Duration("duration", duration),
slog.Any("err", err),
)
} else {
c.logger.Debug(fmt.Sprintf("wmi query %s succeeded after %s", query.Name, duration))
}
ch <- prometheus.MustNewConstMetric(
c.querySuccessDesc,
prometheus.GaugeValue,
success,
query.Name,
)
ch <- prometheus.MustNewConstMetric(
c.queryDurationDesc,
prometheus.GaugeValue,
duration.Seconds(),
query.Name,
)
}
return errors.Join(errs...)
}
func (c *Collector) collectQuery(ch chan<- prometheus.Metric, query Query, deadline time.Time) error {
// Zero means no timeout, which leaves the MI default in place.
var timeout time.Duration
if !deadline.IsZero() {
timeout = time.Until(deadline)
// MI timeouts have microsecond resolution and treat zero as unset,
// so a query is not started without a usable amount of time left.
if timeout < minQueryTimeout {
return errScrapeTimeout
}
}
labelValues := make([]string, len(query.LabelProperties))
// A property that cannot be read usually fails for every instance, so
// each property reports at most one error per scrape.
propertyErrs := make(map[string]error)
err := c.miSession.QueryFunc(query.namespace, query.query, timeout, func(instance *mi.Instance) error {
// The MI timeout does not stop a query that keeps returning
// instances, so the deadline is also checked per instance.
if !deadline.IsZero() && time.Now().After(deadline) {
return errScrapeTimeout
}
for i, labelProperty := range query.LabelProperties {
element, err := instance.GetElement(labelProperty.Name)
if err != nil {
return fmt.Errorf("failed to read label property %s: %w", labelProperty.Name, err)
}
if element.IsNull() {
labelValues[i] = ""
continue
}
labelValues[i], err = element.String()
if err != nil {
return fmt.Errorf("failed to read label property %s: %w", labelProperty.Name, err)
}
}
for _, property := range query.Properties {
if _, failed := propertyErrs[property.Name]; failed {
continue
}
element, err := instance.GetElement(property.Name)
if err != nil {
propertyErrs[property.Name] = fmt.Errorf("failed to read property %s: %w", property.Name, err)
continue
}
// WMI reports properties without a value as null. Exporting them
// as 0 would be indistinguishable from a real 0, so skip them.
if element.IsNull() {
continue
}
value, err := element.Float64()
if err != nil {
propertyErrs[property.Name] = fmt.Errorf("failed to read property %s: %w", property.Name, err)
continue
}
ch <- prometheus.MustNewConstMetric(
property.desc,
property.metricType,
value,
labelValues...,
)
}
return nil
})
if err != nil {
return err
}
errs := make([]error, 0, len(propertyErrs))
for _, property := range query.Properties {
if err, ok := propertyErrs[property.Name]; ok {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// firstCommonLabel returns the first label name in labelNames that is also a
// key of labels.
func firstCommonLabel(labelNames []string, labels map[string]string) (string, bool) {
for _, name := range labelNames {
if _, ok := labels[name]; ok {
return name, true
}
}
return "", false
}
// sanitizeMetricName turns an arbitrary string into a valid Prometheus metric
// or label 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), "_"), "_")
}
// seriesKey identifies the series of a property without label properties: its
// metric name and constant labels.
func seriesKey(metric string, labels map[string]string) string {
var sb strings.Builder
sb.WriteString(metric)
for _, name := range slices.Sorted(maps.Keys(labels)) {
sb.WriteString("\xff" + name + "=" + labels[name])
}
return sb.String()
}
+385
View File
@@ -0,0 +1,385 @@
// 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 wmi_test
import (
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
"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"
)
// collectorAdapter bridges wmi.Collector's Collect(ch, time.Duration) error
// signature to the prometheus.Collector interface. The Collect error is
// discarded: these tests deliberately configure failing queries, which are
// reported via query_success=0 while every other metric is still emitted.
type collectorAdapter struct {
*wmi.Collector
maxScrapeDuration time.Duration
}
// Describe implements the prometheus.Collector interface.
func (collectorAdapter) Describe(chan<- *prometheus.Desc) {}
// Collect implements the prometheus.Collector interface.
func (a collectorAdapter) Collect(ch chan<- prometheus.Metric) {
_ = a.Collector.Collect(ch, a.maxScrapeDuration)
}
// scrape builds c against a live MI session and returns its exposition.
func scrape(t *testing.T, c *wmi.Collector) string {
t.Helper()
return scrapeWithTimeout(t, c, 5*time.Second)
}
// scrapeWithTimeout is scrape with a custom scrape timeout.
func scrapeWithTimeout(t *testing.T, c *wmi.Collector, maxScrapeDuration time.Duration) string {
t.Helper()
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
reg := prometheus.NewRegistry()
reg.MustRegister(collectorAdapter{c, maxScrapeDuration})
rw := httptest.NewRecorder()
promhttp.HandlerFor(reg, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}).ServeHTTP(rw, &http.Request{})
return rw.Body.String()
}
// collect builds c against a live MI session and returns the number of emitted
// metrics and the Collect error.
func collect(t *testing.T, c *wmi.Collector) (int, error) {
t.Helper()
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
ch := make(chan prometheus.Metric, 1000)
err := c.Collect(ch, 5*time.Second)
close(ch)
return len(ch), err
}
// TestCollectorMetrics asserts the property→Desc→metric wiring end to end
// against WMI instances whose values are identical on every Windows host: the
// System Idle Process (Handle 0) and the System process (Handle 4), both with
// parent PID 0 and without an executable path or install date, and the
// primary operating system.
//
// It covers multiple instances per query, auto-generated and explicit metric
// names, gauge vs counter, label properties with default and explicit label
// names, a property used as both label and value, null labels and values,
// constant labels, boolean conversion, a metric name shared by two queries,
// and the query_success semantics for an unknown class, a non-numeric
// property next to a numeric one, and a label property of unsupported type.
func TestCollectorMetrics(t *testing.T) {
t.Parallel()
got := scrape(t, wmi.New(&wmi.Config{
Queries: []wmi.Query{
{
Name: "kernel",
Class: "Win32_Process",
Where: "Handle = 0 OR Handle = 4",
LabelProperties: []wmi.LabelProperty{
{Name: "Name", Label: "process"},
// Default label name: the sanitized, lowercased property name.
{Name: "Handle"},
// Null for both processes, so the label value is empty.
{Name: "ExecutablePath", Label: "path"},
// Also exported as a value below; selected only once.
{Name: "ProcessId", Label: "pid"},
},
Properties: []wmi.Property{
// Auto-named gauge → windows_wmi_kernel_processid.
{Name: "ProcessId"},
// Explicit metric + counter type + help + constant label.
{
Name: "ParentProcessId",
Metric: "windows_wmi_test_parent_pid",
Help: "custom wmi help text",
Type: "counter",
Labels: map[string]string{"foo": "bar"},
},
// Null for every process, so no sample is exported.
{Name: "InstallDate"},
},
},
{
Name: "os",
Class: "Win32_OperatingSystem",
// Whitespace only, so no WHERE clause is generated.
Where: " \t ",
Properties: []wmi.Property{{Name: "Primary"}},
},
{
Name: "shared_a",
Class: "Win32_OperatingSystem",
Properties: []wmi.Property{{Name: "Primary", Metric: "windows_wmi_test_shared", Labels: map[string]string{"query": "a"}}},
},
{
Name: "shared_b",
Class: "Win32_OperatingSystem",
Properties: []wmi.Property{{Name: "Primary", Metric: "windows_wmi_test_shared", Labels: map[string]string{"query": "b"}}},
},
{
Name: "missing_class",
Class: "Win32_WindowsExporterDoesNotExist",
Properties: []wmi.Property{{Name: "Value"}},
},
{
// Caption is a string, which cannot be exported as a sample.
// Primary is still exported.
Name: "partial",
Class: "Win32_OperatingSystem",
Properties: []wmi.Property{{Name: "Caption"}, {Name: "Primary"}},
},
{
// A datetime cannot be used as a label value.
Name: "unsupported_label",
Class: "Win32_OperatingSystem",
LabelProperties: []wmi.LabelProperty{{Name: "LastBootUpTime"}},
Properties: []wmi.Property{{Name: "Primary"}},
},
},
}))
// Durations vary between runs; their presence is asserted separately.
var lines []string
for line := range strings.SplitSeq(got, "\n") {
if !strings.Contains(line, "windows_wmi_query_duration_seconds") {
lines = append(lines, line)
}
}
expected := `# HELP windows_wmi_kernel_processid windows_exporter: custom WMI metric
# TYPE windows_wmi_kernel_processid gauge
windows_wmi_kernel_processid{handle="0",path="",pid="0",process="System Idle Process"} 0
windows_wmi_kernel_processid{handle="4",path="",pid="4",process="System"} 4
# HELP windows_wmi_os_primary windows_exporter: custom WMI metric
# TYPE windows_wmi_os_primary gauge
windows_wmi_os_primary 1
# HELP windows_wmi_partial_primary windows_exporter: custom WMI metric
# TYPE windows_wmi_partial_primary gauge
windows_wmi_partial_primary 1
# HELP windows_wmi_query_success Whether the WMI query and all of its configured properties could be read successfully.
# TYPE windows_wmi_query_success gauge
windows_wmi_query_success{name="kernel"} 1
windows_wmi_query_success{name="missing_class"} 0
windows_wmi_query_success{name="os"} 1
windows_wmi_query_success{name="partial"} 0
windows_wmi_query_success{name="shared_a"} 1
windows_wmi_query_success{name="shared_b"} 1
windows_wmi_query_success{name="unsupported_label"} 0
# HELP windows_wmi_test_parent_pid custom wmi help text
# TYPE windows_wmi_test_parent_pid counter
windows_wmi_test_parent_pid{foo="bar",handle="0",path="",pid="0",process="System Idle Process"} 0
windows_wmi_test_parent_pid{foo="bar",handle="4",path="",pid="4",process="System"} 0
# HELP windows_wmi_test_shared windows_exporter: custom WMI metric
# TYPE windows_wmi_test_shared gauge
windows_wmi_test_shared{query="a"} 1
windows_wmi_test_shared{query="b"} 1
`
require.Equal(t, expected, strings.Join(lines, "\n"))
for _, name := range []string{"kernel", "missing_class", "os", "partial", "shared_a", "shared_b", "unsupported_label"} {
require.Regexp(t, `(?m)^windows_wmi_query_duration_seconds\{name="`+name+`"\} \d`, got)
}
}
// TestCollectorDatetime asserts that a DATETIME timestamp is exported as
// seconds since the Unix epoch, including the UTC offset WMI reports.
func TestCollectorDatetime(t *testing.T) {
t.Parallel()
got := scrape(t, wmi.New(&wmi.Config{
Queries: []wmi.Query{
{
Name: "os",
Class: "Win32_OperatingSystem",
Properties: []wmi.Property{{Name: "LocalDateTime", Metric: "windows_wmi_test_now_timestamp_seconds"}},
},
},
}))
match := regexp.MustCompile(`(?m)^windows_wmi_test_now_timestamp_seconds (\S+)$`).FindStringSubmatch(got)
require.Len(t, match, 2, got)
value, err := strconv.ParseFloat(match[1], 64)
require.NoError(t, err)
require.InDelta(t, float64(time.Now().Unix()), value, 60)
}
// TestCollectPropertyErrorReportedOnce verifies that a property failing for
// every instance is reported once per scrape, not once per instance, and that
// the other properties of those instances are still exported.
func TestCollectPropertyErrorReportedOnce(t *testing.T) {
t.Parallel()
metrics, err := collect(t, wmi.New(&wmi.Config{
Queries: []wmi.Query{
{
Name: "kernel",
Class: "Win32_Process",
Where: "Handle = 0 OR Handle = 4",
LabelProperties: []wmi.LabelProperty{{Name: "Handle"}},
Properties: []wmi.Property{{Name: "Name"}, {Name: "ProcessId"}},
},
},
}))
// Like the registry collector, a failing query is reported via
// query_success and does not fail the whole collector.
require.ErrorIs(t, err, types.ErrNoData)
require.Equal(t, 1, strings.Count(err.Error(), "failed to read property Name"), err.Error())
// Two ProcessId samples plus query_success and query_duration_seconds.
require.Equal(t, 4, metrics)
}
// TestCollectScrapeTimeout verifies that queries are not started once the
// scrape timeout is used up, and are reported as failed.
func TestCollectScrapeTimeout(t *testing.T) {
t.Parallel()
c := wmi.New(&wmi.Config{
Queries: []wmi.Query{
{Name: "a", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary"}}},
{Name: "b", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary"}}, LabelProperties: []wmi.LabelProperty{{Name: "Caption"}}},
},
})
got := scrapeWithTimeout(t, c, time.Nanosecond)
require.Contains(t, got, `windows_wmi_query_success{name="a"} 0`)
require.Contains(t, got, `windows_wmi_query_success{name="b"} 0`)
require.NotContains(t, got, "windows_wmi_a_primary")
require.NotContains(t, got, "windows_wmi_b_primary")
ch := make(chan prometheus.Metric, 10)
err := c.Collect(ch, time.Nanosecond)
close(ch)
require.ErrorIs(t, err, types.ErrNoData)
require.Equal(t, 2, strings.Count(err.Error(), "scrape timeout exceeded"), err.Error())
}
// TestNewWithFlags verifies that the queries flag is parsed as YAML and JSON,
// and that invalid input is rejected at parse time.
func TestNewWithFlags(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
queries string
}{
{
name: "yaml",
queries: `- name: os
class: Win32_OperatingSystem
properties:
- name: Primary`,
},
{
name: "json",
queries: `[{"name":"os","class":"Win32_OperatingSystem","properties":[{"name":"Primary"}]}]`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := kingpin.New("test", "")
c := wmi.NewWithFlags(app)
// The = form is required, because kingpin reads a separate value
// starting with "-", like a YAML list, as another flag.
_, err := app.Parse([]string{"--collector.wmi.queries=" + tc.queries})
require.NoError(t, err)
metrics, err := collect(t, c)
require.NoError(t, err)
// Primary plus query_success and query_duration_seconds.
require.Equal(t, 3, metrics)
})
}
t.Run("invalid", func(t *testing.T) {
t.Parallel()
app := kingpin.New("test", "")
_ = wmi.NewWithFlags(app)
_, err := app.Parse([]string{"--collector.wmi.queries", `[{"name": `})
require.Error(t, err)
})
}
// TestCollectConcurrent runs overlapping scrapes, as concurrent HTTP requests
// do, so the race detector in CI can catch state shared between them.
func TestCollectConcurrent(t *testing.T) {
t.Parallel()
c := wmi.New(&wmi.Config{
Queries: []wmi.Query{
{
Name: "kernel",
Class: "Win32_Process",
Where: "Handle = 0 OR Handle = 4",
LabelProperties: []wmi.LabelProperty{{Name: "Handle"}},
Properties: []wmi.Property{{Name: "ProcessId"}, {Name: "Name"}},
},
},
})
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
ch := make(chan prometheus.Metric, 100)
err := c.Collect(ch, 5*time.Second)
close(ch)
// Name is a string, so every scrape reports exactly that error.
if err == nil || len(ch) != 4 {
t.Errorf("unexpected scrape result: %d metrics, err=%v", len(ch), err)
}
})
}
wg.Wait()
}
+314
View File
@@ -0,0 +1,314 @@
// 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 wmi_test
import (
"log/slog"
"testing"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/stretchr/testify/require"
)
func BenchmarkCollector(b *testing.B) {
queries := `[{"name":"logical_disk","class":"Win32_LogicalDisk","where":"DriveType = 3","label_properties":[{"name":"DeviceID"}],"properties":[{"name":"FreeSpace"},{"name":"Size"}]}]`
testutils.FuncBenchmarkCollector(b, wmi.Name, wmi.NewWithFlags, func(app *kingpin.Application) {
// The queries are only read by the application action, which runs on parse.
_, err := app.Parse([]string{"--collector.wmi.queries", queries})
require.NoError(b, err)
})
}
func TestCollector(t *testing.T) {
t.Parallel()
testutils.TestCollector(t, wmi.New, &wmi.Config{
Queries: []wmi.Query{
{
Name: "os",
Class: "Win32_OperatingSystem",
Properties: []wmi.Property{
{Name: "NumberOfProcesses"},
{Name: "LastBootUpTime", Metric: "windows_wmi_os_last_boot_timestamp_seconds"},
},
},
{
Name: "logical_disk",
Namespace: `root\CIMv2`,
Class: "Win32_LogicalDisk",
Where: "DriveType = 3",
LabelProperties: []wmi.LabelProperty{{Name: "DeviceID", Label: "volume"}},
Properties: []wmi.Property{{Name: "FreeSpace"}, {Name: "Size"}},
},
},
})
}
// newSession returns a live MI session. Build must be validated against a real
// session, because a nil session is rejected before the configuration is
// looked at.
func newSession(t *testing.T) *mi.Session {
t.Helper()
app, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, app.Close()) })
session, err := app.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, session.Close()) })
return session
}
func TestCollectorBuildEmptyConfigWithoutSession(t *testing.T) {
t.Parallel()
require.NoError(t, wmi.New(nil).Build(slog.New(slog.DiscardHandler), nil))
require.Error(t, wmi.New(&wmi.Config{Queries: []wmi.Query{
{Name: "os", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "NumberOfProcesses"}}},
}}).Build(slog.New(slog.DiscardHandler), nil))
}
func TestCollectorBuildErrors(t *testing.T) {
t.Parallel()
session := newSession(t)
valid := func(mutate func(*wmi.Query)) []wmi.Query {
query := wmi.Query{
Name: "disk",
Class: "Win32_LogicalDisk",
LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}},
Properties: []wmi.Property{{Name: "FreeSpace"}},
}
mutate(&query)
return []wmi.Query{query}
}
// Guard against the table passing for the wrong reason.
require.NoError(t, wmi.New(&wmi.Config{Queries: valid(func(*wmi.Query) {})}).Build(slog.New(slog.DiscardHandler), session))
for _, tc := range []struct {
name string
queries []wmi.Query
err string
}{
{
name: "missing name",
queries: valid(func(q *wmi.Query) { q.Name = "" }),
err: "query name is required",
},
{
name: "duplicate name",
queries: append(
valid(func(*wmi.Query) {}),
valid(func(q *wmi.Query) { q.Class = "Win32_Volume" })...,
),
err: "query disk: name is duplicated",
},
{
name: "names differing only by case",
queries: append(
valid(func(*wmi.Query) {}),
valid(func(q *wmi.Query) { q.Name = "DISK" })...,
),
err: "query DISK: name produces the same metric names as query disk",
},
{
name: "names differing only by special characters",
queries: append(
valid(func(q *wmi.Query) { q.Name = "my_disk" }),
valid(func(q *wmi.Query) { q.Name = "my-disk" })...,
),
err: "query my-disk: name produces the same metric names as query my_disk",
},
{
name: "missing class",
queries: valid(func(q *wmi.Query) { q.Class = "" }),
err: `class "" must be a valid WMI class name`,
},
{
name: "class with WQL injection",
queries: valid(func(q *wmi.Query) { q.Class = "Win32_LogicalDisk WHERE 1=1" }),
err: "must be a valid WMI class name",
},
{
name: "no properties",
queries: valid(func(q *wmi.Query) { q.Properties = nil }),
err: "no properties configured",
},
{
name: "invalid property name",
queries: valid(func(q *wmi.Query) { q.Properties = []wmi.Property{{Name: "Free Space"}} }),
err: `property "Free Space" must be a valid WMI property name`,
},
{
name: "duplicate property differing only by case",
queries: valid(func(q *wmi.Query) {
q.Properties = []wmi.Property{{Name: "FreeSpace", Metric: "windows_a"}, {Name: "freespace", Metric: "windows_b"}}
}),
err: `property "freespace" is duplicated`,
},
{
name: "invalid type",
queries: valid(func(q *wmi.Query) { q.Properties[0].Type = "histogram" }),
err: `invalid type "histogram"`,
},
{
name: "invalid label property name",
queries: valid(func(q *wmi.Query) { q.LabelProperties = []wmi.LabelProperty{{Name: ""}} }),
err: `label property "" must be a valid WMI property name`,
},
{
name: "reserved label name",
queries: valid(func(q *wmi.Query) { q.LabelProperties[0].Label = "__volume" }),
err: `"__volume"`,
},
{
name: "reserved constant label name",
queries: valid(func(q *wmi.Query) { q.Properties[0].Labels = map[string]string{"__foo": "bar"} }),
err: `"__foo"`,
},
{
name: "duplicate label",
queries: valid(func(q *wmi.Query) {
q.LabelProperties = []wmi.LabelProperty{{Name: "DeviceID", Label: "volume"}, {Name: "VolumeName", Label: "volume"}}
}),
err: `label "volume" is duplicated`,
},
{
name: "constant label collides with label property",
queries: valid(func(q *wmi.Query) { q.Properties[0].Labels = map[string]string{"deviceid": "x"} }),
err: `constant label "deviceid" is already set by a label property`,
},
{
name: "properties producing identical series",
queries: valid(func(q *wmi.Query) {
q.Properties = []wmi.Property{{Name: "FreeSpace", Metric: "windows_disk"}, {Name: "Size", Metric: "windows_disk"}}
}),
err: `properties "FreeSpace" and "Size" produce identical series windows_disk`,
},
{
name: "queries without label properties producing identical series",
queries: append(
valid(func(q *wmi.Query) {
q.LabelProperties = nil
q.Properties[0].Metric = "windows_disk"
}),
valid(func(q *wmi.Query) {
q.Name = "volume"
q.LabelProperties = nil
q.Properties[0].Metric = "windows_disk"
})...,
),
err: `query volume: property "FreeSpace" produces the same series windows_disk as query disk`,
},
{
name: "explicit metric reserved by the collector",
queries: valid(func(q *wmi.Query) { q.Properties[0].Metric = "windows_wmi_query_success" }),
err: `metric "windows_wmi_query_success" is reserved`,
},
{
name: "derived metric reserved by the collector",
queries: valid(func(q *wmi.Query) {
q.Name = "query"
q.Properties = []wmi.Property{{Name: "Duration_Seconds"}}
}),
err: `metric "windows_wmi_query_duration_seconds" is reserved`,
},
{
name: "shared metric name with inconsistent help text",
queries: valid(func(q *wmi.Query) {
q.Properties = []wmi.Property{
{Name: "FreeSpace", Metric: "windows_disk", Help: "a", Labels: map[string]string{"kind": "free"}},
{Name: "Size", Metric: "windows_disk", Help: "b", Labels: map[string]string{"kind": "size"}},
}
}),
err: `metric "windows_disk" must have the same help text, type and label names`,
},
{
name: "shared metric name with inconsistent label names",
queries: append(
valid(func(q *wmi.Query) { q.Properties[0].Metric = "windows_disk" }),
valid(func(q *wmi.Query) {
q.Name = "volume"
q.LabelProperties = nil
q.Properties[0].Metric = "windows_disk"
})...,
),
err: `metric "windows_disk" must have the same help text, type and label names`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := wmi.New(&wmi.Config{Queries: tc.queries})
require.ErrorContains(t, c.Build(slog.New(slog.DiscardHandler), session), tc.err)
})
}
}
// TestCollectorBuildValid covers configurations close to the rejected ones in
// TestCollectorBuildErrors that must still be accepted.
func TestCollectorBuildValid(t *testing.T) {
t.Parallel()
session := newSession(t)
for _, tc := range []struct {
name string
queries []wmi.Query
}{
{
name: "whitespace-only where",
queries: []wmi.Query{
{Name: "os", Class: "Win32_OperatingSystem", Where: " \t ", Properties: []wmi.Property{{Name: "Primary"}}},
},
},
{
name: "queries with label properties sharing a metric",
queries: []wmi.Query{
{Name: "fixed", Class: "Win32_LogicalDisk", Where: "DriveType = 3", LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}}, Properties: []wmi.Property{{Name: "Size", Metric: "windows_disk_size_bytes"}}},
{Name: "removable", Class: "Win32_LogicalDisk", Where: "DriveType = 2", LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}}, Properties: []wmi.Property{{Name: "Size", Metric: "windows_disk_size_bytes"}}},
},
},
{
name: "queries without label properties sharing a metric with distinct constant labels",
queries: []wmi.Query{
{Name: "a", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary", Metric: "windows_os_primary", Labels: map[string]string{"query": "a"}}}},
{Name: "b", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary", Metric: "windows_os_primary", Labels: map[string]string{"query": "b"}}}},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := wmi.New(&wmi.Config{Queries: tc.queries})
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), session))
})
}
}