Files
windows_exporter/internal/collector/wmi/wmi.go
T
2026-10-12 15:54:25 +02:00

642 lines
18 KiB
Go

// 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"
"io"
"log/slog"
"maps"
"regexp"
"slices"
"strings"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/labelcache"
"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
// labels keeps the label pairs of the returned instances between scrapes,
// so that a query returning many instances doesn't rebuild them each time.
labels labelcache.Cache
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
}
// Reject unknown keys, so that a typo like "label_property" fails instead
// of silently dropping the setting. A value without a document, e.g. only
// comments, means no queries, like in the configuration file.
decoder := yaml.NewDecoder(strings.NewReader(queries))
decoder.KnownFields(true)
if err := decoder.Decode(&c.config.Queries); err != nil && !errors.Is(err, io.EOF) {
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 {
c.labels.Reset()
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.elementName, err = mi.NewElementName(labelProperty.Name); err != nil {
errs = append(errs, fmt.Errorf("query %s: label property %q: %w", query.Name, labelProperty.Name, err))
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 property.elementName, err = mi.NewElementName(property.Name); err != nil {
errs = append(errs, fmt.Errorf("query %s: property %q: %w", query.Name, property.Name, err))
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)
}
scrape := c.labels.Begin()
defer scrape.Commit()
var errs []error
for _, query := range c.queries {
startTime := time.Now()
err := c.collectQuery(ch, scrape, 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: %s: %w",
query.Name,
err.Error(),
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("wmi query succeeded",
slog.String("query", query.Name),
slog.Duration("duration", 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, scrape *labelcache.Scrape, 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.GetElementByName(labelProperty.elementName)
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.GetElementByName(property.elementName)
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 <- scrape.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()
}