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))
})
}
}
+4 -1
View File
@@ -107,6 +107,7 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) {
var (
valueBuf [5]uint64
valueType ValueType
flags uint32
)
r0, _, _ := syscall.SyscallN(
@@ -115,7 +116,7 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) {
uintptr(unsafe.Pointer(elementNameUTF16)),
uintptr(unsafe.Pointer(&valueBuf)),
uintptr(unsafe.Pointer(&valueType)),
0,
uintptr(unsafe.Pointer(&flags)),
0,
)
@@ -127,6 +128,8 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) {
value: uintptr(valueBuf[0]),
arrayLen: uint32(valueBuf[1]),
valueType: valueType,
flags: flags,
raw: valueBuf,
}, nil
}
+116
View File
@@ -873,3 +873,119 @@ func Test_MI_QueryUnmarshal_Uint16Array_WrongType(t *testing.T) {
err = application.Close()
require.NoError(t, err)
}
func Test_MI_QueryFunc(t *testing.T) {
application, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, application.Close()) })
session, err := application.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, session.Close()) })
query, err := mi.NewQuery("SELECT LocalDateTime, NumberOfProcesses, Caption FROM Win32_OperatingSystem")
require.NoError(t, err)
var calls int
err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(instance *mi.Instance) error {
calls++
// LocalDateTime is a DATETIME timestamp carrying the local UTC offset;
// decoding it must land close to the current wall clock.
element, err := instance.GetElement("LocalDateTime")
require.NoError(t, err)
require.False(t, element.IsNull())
localDateTime, err := element.Float64()
require.NoError(t, err)
require.InDelta(t, float64(time.Now().Unix()), localDateTime, 60)
element, err = instance.GetElement("NumberOfProcesses")
require.NoError(t, err)
processes, err := element.Float64()
require.NoError(t, err)
require.Positive(t, processes)
element, err = instance.GetElement("Caption")
require.NoError(t, err)
caption, err := element.String()
require.NoError(t, err)
require.Contains(t, caption, "Windows")
return nil
})
require.NoError(t, err)
require.Equal(t, 1, calls)
// An error returned by the callback aborts the query and is passed through.
errStop := errors.New("stop")
query, err = mi.NewQuery("SELECT Name FROM Win32_Process")
require.NoError(t, err)
calls = 0
err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error {
calls++
return errStop
})
require.ErrorIs(t, err, errStop)
require.Equal(t, 1, calls)
// The cancelled query must leave the session usable.
calls = 0
err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error {
calls++
return nil
})
require.NoError(t, err)
require.Greater(t, calls, 1)
// Invalid classes surface as an error rather than an empty result.
query, err = mi.NewQuery("SELECT Name FROM Win32_DoesNotExist")
require.NoError(t, err)
err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error {
return nil
})
require.Error(t, err)
}
func Test_MI_Operation_CloseEarly(t *testing.T) {
application, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, application.Close()) })
session, err := application.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, session.Close()) })
// Closing an operation with pending results cancels it instead of reading
// them, and must leave the session usable.
for range 3 {
operation, err := session.QueryInstances(mi.OperationFlagsStandardRTTI, nil, mi.NamespaceRootCIMv2, mi.QueryDialectWQL, "SELECT Name FROM Win32_Process")
require.NoError(t, err)
instance, moreResults, err := operation.GetInstance()
require.NoError(t, err)
require.NotNil(t, instance)
require.True(t, moreResults)
require.NoError(t, operation.Close())
}
var processes []win32Process
query, err := mi.NewQuery("SELECT Name FROM Win32_Process WHERE Handle = 0")
require.NoError(t, err)
require.NoError(t, session.Query(&processes, mi.NamespaceRootCIMv2, query, 5*time.Second))
require.Equal(t, []win32Process{{Name: "System Idle Process"}}, processes)
}
+22 -7
View File
@@ -51,6 +51,11 @@ type Operation struct {
reserved1 uint64
reserved2 uintptr
ft *OperationFT
// completed is set once GetInstance has returned the final result. It is
// Go-only state after the MI_Operation fields, which MI never writes past;
// every Operation is allocated in Go.
completed bool
}
// OperationFT represents the function table for Operation.
@@ -106,14 +111,17 @@ func (o *Operation) Close() error {
return ErrNotInitialized
}
moreResults := true
// MI_Operation_Close blocks until the final result has been delivered, so
// pending results must be read first. Reading them would cost the full
// remaining result set, so the operation is cancelled, which makes the
// final result arrive right away. A fully read operation needs neither.
if !o.completed {
_ = o.Cancel()
var err error
for moreResults {
_, moreResults, err = o.GetInstance()
if err != nil {
break
for !o.completed {
if _, _, err := o.GetInstance(); err != nil {
break
}
}
}
@@ -190,6 +198,9 @@ func (o *Operation) GetInstance() (*Instance, bool, error) {
errorMessage = fmt.Sprintf(" (%s)", errorMessage)
}
// A failed result is always the final one.
o.completed = true
return nil, false, fmt.Errorf("instance result: %w%s", instanceResult, errorMessage)
}
@@ -197,6 +208,10 @@ func (o *Operation) GetInstance() (*Instance, bool, error) {
return nil, false, result
}
if moreResults != True {
o.completed = true
}
return instance, moreResults == True, nil
}
+105 -19
View File
@@ -398,29 +398,115 @@ func (s *Session) QueryUnmarshal(dst any,
// Query queries for a set of instances based on a query expression.
func (s *Session) Query(dst any, namespaceName Namespace, queryExpression Query, queryTimeout time.Duration) error {
if queryTimeout < 0 {
return s.QueryUnmarshal(dst, OperationFlagsStandardRTTI, nil, namespaceName, QueryDialectWQL, queryExpression)
}
app, err := s.GetApplication()
operationOptions, err := s.newOperationOptions(queryTimeout)
if err != nil {
return fmt.Errorf("failed to get application: %w", err)
return err
}
operationOptions, err := app.NewOperationOptions()
if err != nil {
return fmt.Errorf("failed to create operation options: %w", err)
}
defer func() {
_ = operationOptions.Delete()
}()
if queryTimeout > 0 {
if err = operationOptions.SetTimeout(queryTimeout); err != nil {
return fmt.Errorf("failed to set timeout: %w", err)
}
if operationOptions != nil {
defer func() {
_ = operationOptions.Delete()
}()
}
return s.QueryUnmarshal(dst, OperationFlagsStandardRTTI, operationOptions, namespaceName, QueryDialectWQL, queryExpression)
}
// QueryFunc runs a WQL query and calls fn for every returned instance, in
// order. It is meant for queries whose result shape is only known at runtime,
// where [Session.Query] cannot unmarshal into a struct.
//
// The instance, and every element read from it, is owned by the operation and
// only valid until fn returns. If fn returns an error, the query is cancelled
// and that error is returned.
func (s *Session) QueryFunc(namespaceName Namespace, queryExpression Query, queryTimeout time.Duration, fn func(*Instance) error) error {
if s == nil || s.ft == nil {
return ErrNotInitialized
}
operationOptions, err := s.newOperationOptions(queryTimeout)
if err != nil {
return err
}
if operationOptions != nil {
// Deferred first so the options outlive the operation that uses them.
defer func() {
_ = operationOptions.Delete()
}()
} else {
operationOptions = s.defaultOperationOptions
}
operation := &Operation{}
r0, _, _ := syscall.SyscallN(
s.ft.QueryInstances,
uintptr(unsafe.Pointer(s)),
uintptr(OperationFlagsStandardRTTI),
uintptr(unsafe.Pointer(operationOptions)),
uintptr(unsafe.Pointer(namespaceName)),
uintptr(unsafe.Pointer(QueryDialectWQL)),
uintptr(unsafe.Pointer(queryExpression)),
0,
uintptr(unsafe.Pointer(operation)),
)
if result := ResultError(r0); !errors.Is(result, MI_RESULT_OK) {
return fmt.Errorf("failed to query instances: %w", result)
}
// Close cancels the operation if fn aborted it early.
defer func() {
_ = operation.Close()
}()
for {
instance, moreResults, err := operation.GetInstance()
if err != nil {
return fmt.Errorf("failed to get instance: %w", err)
}
if instance == nil {
return nil
}
if err := fn(instance); err != nil {
return err
}
if !moreResults {
return nil
}
}
}
// newOperationOptions creates operation options carrying queryTimeout. A zero
// timeout leaves the MI default in place; a negative timeout returns nil
// options, so the session defaults are used. The caller must delete non-nil
// options once the operation is closed.
func (s *Session) newOperationOptions(queryTimeout time.Duration) (*OperationOptions, error) {
if queryTimeout < 0 {
return nil, nil //nolint:nilnil
}
app, err := s.GetApplication()
if err != nil {
return nil, fmt.Errorf("failed to get application: %w", err)
}
operationOptions, err := app.NewOperationOptions()
if err != nil {
return nil, fmt.Errorf("failed to create operation options: %w", err)
}
if queryTimeout > 0 {
if err = operationOptions.SetTimeout(queryTimeout); err != nil {
_ = operationOptions.Delete()
return nil, fmt.Errorf("failed to set timeout: %w", err)
}
}
return operationOptions, nil
}
+159 -5
View File
@@ -21,6 +21,8 @@ import (
"errors"
"fmt"
"math"
"strconv"
"time"
"unsafe"
"golang.org/x/sys/windows"
@@ -65,10 +67,166 @@ const (
ValueTypeARRAY ValueType = 16
)
// flagNull is MI_FLAG_NULL. MI_Instance_GetElement sets it in the returned
// flags when the element has no value.
const flagNull uint32 = 0x20000000
type Element struct {
value uintptr
arrayLen uint32
valueType ValueType
flags uint32
// raw holds the complete MI_Value union. Scalars and pointers live in the
// first word, but MI_Datetime is stored inline and spans 36 bytes.
raw [5]uint64
}
// IsNull reports whether the element has no value.
func (e *Element) IsNull() bool {
return e.flags&flagNull != 0
}
// Float64 converts a boolean, numeric or datetime element to a float64.
// Booleans become 0 or 1, timestamps become seconds since the Unix epoch and
// intervals become seconds.
func (e *Element) Float64() (float64, error) {
switch e.valueType {
case ValueTypeBOOLEAN:
if e.value == 1 {
return 1, nil
}
return 0, nil
case ValueTypeUINT8:
return float64(uint8(e.value)), nil
case ValueTypeSINT8:
return float64(int8(e.value)), nil
case ValueTypeUINT16:
return float64(uint16(e.value)), nil
case ValueTypeSINT16:
return float64(int16(e.value)), nil
case ValueTypeUINT32:
return float64(uint32(e.value)), nil
case ValueTypeSINT32:
return float64(int32(e.value)), nil
case ValueTypeUINT64:
return float64(uint64(e.value)), nil
case ValueTypeSINT64:
return float64(int64(e.value)), nil
case ValueTypeREAL32:
return float64(math.Float32frombits(uint32(e.value))), nil
case ValueTypeREAL64:
return math.Float64frombits(uint64(e.value)), nil
case ValueTypeDATETIME:
dt := e.datetime()
if dt.IsTimestamp {
return float64(dt.Timestamp.Time().UnixMicro()) / 1e6, nil
}
return dt.Interval.TotalSeconds(), nil
default:
return 0, fmt.Errorf("unsupported value type for numeric conversion: %d", e.valueType)
}
}
// String converts a string, boolean or integer element to its string form.
func (e *Element) String() (string, error) {
switch e.valueType {
case ValueTypeSTRING:
if e.value == 0 {
return "", nil
}
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(e.value))), nil
case ValueTypeBOOLEAN:
return strconv.FormatBool(e.value == 1), nil
case ValueTypeUINT8:
return strconv.FormatUint(uint64(uint8(e.value)), 10), nil
case ValueTypeUINT16:
return strconv.FormatUint(uint64(uint16(e.value)), 10), nil
case ValueTypeUINT32:
return strconv.FormatUint(uint64(uint32(e.value)), 10), nil
case ValueTypeUINT64:
return strconv.FormatUint(uint64(e.value), 10), nil
case ValueTypeSINT8:
return strconv.FormatInt(int64(int8(e.value)), 10), nil
case ValueTypeSINT16:
return strconv.FormatInt(int64(int16(e.value)), 10), nil
case ValueTypeSINT32:
return strconv.FormatInt(int64(int32(e.value)), 10), nil
case ValueTypeSINT64:
return strconv.FormatInt(int64(e.value), 10), nil
default:
return "", fmt.Errorf("unsupported value type for string conversion: %d", e.valueType)
}
}
// datetime decodes the inline MI_Datetime: an MI_Uint32 isTimestamp followed
// by an MI_Timestamp or an MI_Interval of eight 32-bit words.
func (e *Element) datetime() Datetime {
words := (*[9]uint32)(unsafe.Pointer(&e.raw))
if words[0] != 0 {
return Datetime{
IsTimestamp: true,
Timestamp: &Timestamp{
Year: words[1],
Month: words[2],
Day: words[3],
Hour: words[4],
Minute: words[5],
Second: words[6],
Microseconds: words[7],
UTC: int32(words[8]),
},
}
}
return Datetime{
Interval: &Interval{
Days: words[1],
Hours: words[2],
Minutes: words[3],
Seconds: words[4],
Microseconds: words[5],
},
}
}
// Time converts the timestamp to a time.Time. UTC is the offset of the
// recorded local time from UTC, in minutes.
func (t *Timestamp) Time() time.Time {
return time.Date(
int(t.Year), time.Month(t.Month), int(t.Day),
int(t.Hour), int(t.Minute), int(t.Second), int(t.Microseconds)*int(time.Microsecond),
time.FixedZone("", int(t.UTC)*60),
)
}
// TotalSeconds returns the length of the interval in seconds. Unlike
// [Interval.Duration], it covers the full range of an MI_Interval, including
// the CIM "infinite" interval of 99999999 days.
func (i *Interval) TotalSeconds() float64 {
return float64(i.Days)*86400 +
float64(i.Hours)*3600 +
float64(i.Minutes)*60 +
float64(i.Seconds) +
float64(i.Microseconds)/1e6
}
// Duration converts the interval to a time.Duration. Intervals longer than
// about 292 years exceed time.Duration and are capped at its maximum.
func (i *Interval) Duration() time.Duration {
if i.TotalSeconds() >= float64(math.MaxInt64)/float64(time.Second) {
return time.Duration(math.MaxInt64)
}
return time.Duration(i.Days)*24*time.Hour +
time.Duration(i.Hours)*time.Hour +
time.Duration(i.Minutes)*time.Minute +
time.Duration(i.Seconds)*time.Second +
time.Duration(i.Microseconds)*time.Microsecond
}
func (e *Element) GetValue() (any, error) {
@@ -98,11 +256,7 @@ func (e *Element) GetValue() (any, error) {
case ValueTypeCHAR16:
return uint16(e.value), nil
case ValueTypeDATETIME:
if e.value == 0 {
return nil, errors.New("invalid pointer: value is nil")
}
return *(*Datetime)(unsafe.Pointer(e.value)), nil
return e.datetime(), nil
case ValueTypeSTRING:
if e.value == 0 {
return nil, errors.New("invalid pointer: value is nil")
+176
View File
@@ -0,0 +1,176 @@
// 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 mi
import (
"math"
"testing"
"time"
"unsafe"
"github.com/stretchr/testify/require"
)
// newElement builds an Element the way Instance.GetElement does, from a raw
// MI_Value buffer.
func newElement(valueType ValueType, raw [5]uint64, flags uint32) *Element {
return &Element{
value: uintptr(raw[0]),
arrayLen: uint32(raw[1]),
valueType: valueType,
flags: flags,
raw: raw,
}
}
func TestElementFloat64(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
valueType ValueType
raw uint64
want float64
}{
{"bool true", ValueTypeBOOLEAN, 1, 1},
{"bool false", ValueTypeBOOLEAN, 0, 0},
{"uint8 ignores high bits", ValueTypeUINT8, 0xFF01, 1},
{"sint8 negative", ValueTypeSINT8, 0xFF, -1},
{"sint16 negative", ValueTypeSINT16, 0xFFFE, -2},
{"uint32", ValueTypeUINT32, math.MaxUint32, math.MaxUint32},
{"sint32 negative", ValueTypeSINT32, 0xFFFFFFFD, -3},
{"uint64", ValueTypeUINT64, 5_000_000_000, 5_000_000_000},
{"sint64 negative", ValueTypeSINT64, math.MaxUint64, -1},
{"real32", ValueTypeREAL32, uint64(math.Float32bits(7.5)), 7.5},
{"real64", ValueTypeREAL64, math.Float64bits(-0.125), -0.125},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := newElement(tc.valueType, [5]uint64{tc.raw}, 0).Float64()
require.NoError(t, err)
require.InDelta(t, tc.want, got, 0)
})
}
_, err := newElement(ValueTypeSTRING, [5]uint64{}, 0).Float64()
require.Error(t, err)
}
func TestElementFloat64Datetime(t *testing.T) {
t.Parallel()
t.Run("timestamp", func(t *testing.T) {
t.Parallel()
var raw [5]uint64
// 2026-10-07 14:30:15.25 at UTC+120 minutes is 12:30:15.25 UTC.
words := (*[10]uint32)(unsafe.Pointer(&raw))
*words = [10]uint32{1, 2026, 10, 7, 14, 30, 15, 250000, 120}
got, err := newElement(ValueTypeDATETIME, raw, 0).Float64()
require.NoError(t, err)
want := time.Date(2026, 10, 7, 12, 30, 15, 250_000_000, time.UTC)
require.InDelta(t, float64(want.UnixMilli())/1e3, got, 1e-6)
})
t.Run("interval", func(t *testing.T) {
t.Parallel()
var raw [5]uint64
words := (*[10]uint32)(unsafe.Pointer(&raw))
*words = [10]uint32{0, 1, 2, 3, 4, 500000}
got, err := newElement(ValueTypeDATETIME, raw, 0).Float64()
require.NoError(t, err)
require.InDelta(t, (26*time.Hour + 3*time.Minute + 4500*time.Millisecond).Seconds(), got, 1e-9)
})
t.Run("timestamp west of UTC", func(t *testing.T) {
t.Parallel()
var raw [5]uint64
// The CIM_DATETIME 20231015123000.000000-420 is 12:30 at UTC-7,
// which is 19:30 UTC. GitHub runners use UTC, so this sign is only
// covered here.
words := (*[10]uint32)(unsafe.Pointer(&raw))
utcOffset := int32(-420)
*words = [10]uint32{1, 2023, 10, 15, 12, 30, 0, 0, uint32(utcOffset)}
got, err := newElement(ValueTypeDATETIME, raw, 0).Float64()
require.NoError(t, err)
require.InDelta(t, float64(time.Date(2023, 10, 15, 19, 30, 0, 0, time.UTC).Unix()), got, 1e-6)
})
t.Run("infinite interval", func(t *testing.T) {
t.Parallel()
var raw [5]uint64
// The CIM "infinite" interval 99999999235959.000000:000 overflows
// time.Duration, which must not wrap around.
words := (*[10]uint32)(unsafe.Pointer(&raw))
*words = [10]uint32{0, 99999999, 23, 59, 59, 0}
element := newElement(ValueTypeDATETIME, raw, 0)
got, err := element.Float64()
require.NoError(t, err)
require.InDelta(t, 99999999*86400.0+86399, got, 1)
require.Equal(t, time.Duration(math.MaxInt64), element.datetime().Interval.Duration())
})
}
func TestElementString(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
valueType ValueType
raw uint64
want string
}{
{"bool", ValueTypeBOOLEAN, 1, "true"},
{"uint16", ValueTypeUINT16, 0x1_0005, "5"},
{"uint64", ValueTypeUINT64, math.MaxUint64, "18446744073709551615"},
{"sint32 negative", ValueTypeSINT32, 0xFFFFFFFF, "-1"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := newElement(tc.valueType, [5]uint64{tc.raw}, 0).String()
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
_, err := newElement(ValueTypeREAL64, [5]uint64{}, 0).String()
require.Error(t, err)
}
func TestElementIsNull(t *testing.T) {
t.Parallel()
require.True(t, newElement(ValueTypeUINT32, [5]uint64{}, flagNull).IsNull())
require.False(t, newElement(ValueTypeUINT32, [5]uint64{}, 0).IsNull())
}