mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 18:19:04 +02:00
Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com>
642 lines
18 KiB
Go
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()
|
|
}
|