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
+1
View File
@@ -60,6 +60,7 @@ A Prometheus exporter for Windows machines.
| [udp](docs/collector.udp.md) | UDP connections | |
| [update](docs/collector.update.md) | Windows Update Service | |
| [vmware](docs/collector.vmware.md) | Performance counters installed by the Vmware Guest agent | |
| [wmi](docs/collector.wmi.md) | Custom WMI query metrics | |
<!-- textlint-enable terminology -->
See the linked documentation on each collector for more information on reported metrics, configuration settings and usage examples.
+1
View File
@@ -47,3 +47,4 @@ This directory contains documentation of the collectors in the windows_exporter,
- [`udp`](collector.udp.md)
- [`update`](collector.update.md)
- [`vmware`](collector.vmware.md)
- [`wmi`](collector.wmi.md)
+353
View File
@@ -0,0 +1,353 @@
# wmi collector
The wmi collector runs user-defined WMI queries and exposes numeric properties of
the returned instances as named metrics.
Like the [performancecounter](collector.performancecounter.md) and
[registry](collector.registry.md) collectors, each property is mapped to its own
metric: the query is a grouping container, and every property under it declares
the metric name, type, and labels it is exported as. Unlike those collectors, a
query usually returns several instances, so properties can also be turned into
labels that tell the instances apart.
> [!NOTE]
> Prefer the [performancecounter](collector.performancecounter.md) collector when
> the data is also available as a performance counter. WMI queries are
> considerably more expensive, and some WMI providers take hundreds of
> milliseconds per query.
| | |
|---------------------|----------|
| Metric name prefix | `wmi` |
| Data source | WMI (MI) |
| Enabled by default? | No |
## Flags
### `--collector.wmi.queries`
Queries is a list of WMI queries to run. The value takes the form of a JSON array
of objects. YAML is supported.
> [!CAUTION]
> If you are using a configuration file, the value must be kept as a string.
>
> Use a `|-` to keep the value as a string.
#### Example
```yaml
collector:
wmi:
queries: |-
- name: logical_disk
class: Win32_LogicalDisk
where: DriveType = 3
label_properties:
- name: DeviceID
label: volume
properties:
- name: FreeSpace
metric: windows_wmi_logical_disk_free_bytes
help: Free space on the logical disk in bytes.
- name: Size
metric: windows_wmi_logical_disk_size_bytes
help: Size of the logical disk in bytes.
- name: os
class: Win32_OperatingSystem
properties:
- name: LastBootUpTime
metric: windows_wmi_os_last_boot_timestamp_seconds
help: Time of the last boot as a Unix timestamp.
- name: NumberOfProcesses
```
#### Schema
YAML:
```yaml
- name: logical_disk # required, unique ID for the query
namespace: root/CIMv2 # optional
class: Win32_LogicalDisk # required
where: DriveType = 3 # optional
label_properties: # optional
- name: DeviceID # WMI property name
label: volume # optional
properties:
- name: FreeSpace # WMI property name
metric: windows_wmi_logical_disk_free_bytes # optional
help: Free space on the logical disk in bytes. # optional
type: gauge # optional
labels: # optional
source: wmi
```
JSON:
```json
[
{
"name": "logical_disk",
"namespace": "root/CIMv2",
"class": "Win32_LogicalDisk",
"where": "DriveType = 3",
"label_properties": [
{ "name": "DeviceID", "label": "volume" }
],
"properties": [
{
"name": "FreeSpace",
"metric": "windows_wmi_logical_disk_free_bytes",
"help": "Free space on the logical disk in bytes.",
"type": "gauge",
"labels": { "source": "wmi" }
}
]
}
]
```
The collector builds the WQL statement from the configuration:
```
SELECT <label_properties>, <properties> FROM <class> [WHERE <where>]
```
#### name
Required, unique ID for the query. It is used as the `name` label on
`windows_wmi_query_success` and `windows_wmi_query_duration_seconds`, to identify
the query in logs, and to seed auto-generated metric names (see
[Metric naming](#metric-naming)). Duplicates are rejected at build time, and so
are names that only differ in case or special characters (`os` and `OS`, or
`my_disk` and `my-disk`), because they produce the same metric names.
#### namespace
The WMI namespace of the class. Optional — defaults to `root/CIMv2`. Backslashes
are accepted as well, for example `root\Microsoft\Windows\Storage`.
#### class
The WMI class to query, for example `Win32_LogicalDisk`. Required. Only letters,
digits and `_` are allowed.
#### where
An optional WQL condition appended as the `WHERE` clause, for example
`DriveType = 3` or `Name LIKE 'sql%'`. It is passed to WMI verbatim.
#### label_properties
The list of properties exported as labels on every metric of the query. Use them
to tell the returned instances apart, typically with the key property of the
class (`DeviceID`, `Name`, …). Optional.
String, boolean and integer properties are supported. A property without a value
(`null`) becomes an empty label value. A label property of any other type, for
example a datetime, fails the whole query at scrape time, and none of its
metrics are exported.
The label properties must uniquely identify every returned instance. Two
instances with identical label values produce duplicate series, which are
dropped and logged at scrape time.
#### label_properties Sub-Schema
##### name
The name of the WMI property. Required.
##### label
The name of the label. Optional — defaults to the property name, lowercased, with
every character that is not a letter or digit replaced by `_` and leading and
trailing `_` trimmed. Each label must be unique within a query.
#### properties
The list of WMI properties to export as metrics. At least one property must be
listed. Property names are matched case-insensitively, and a property listed more
than once under one query is rejected as a duplicate.
The following property types are supported:
| WMI type | Exported value |
| --- | --- |
| `boolean` | `1` for true, `0` for false |
| `uint8`/`sint8` … `uint64`/`sint64` | the number |
| `real32`, `real64` | the number |
| `datetime` (timestamp) | seconds since the Unix epoch |
| `datetime` (interval) | seconds |
A property without a value (`null`) is skipped for that instance rather than
exported as `0`. A property of any other type, for example a string or an array,
fails the query at scrape time (see `windows_wmi_query_success`).
#### properties Sub-Schema
##### name
The name of the WMI property. Required.
##### metric
The name of the metric to expose. Optional — if omitted, a name is generated
automatically. See [Metric naming](#metric-naming) for the exact rules and
examples.
Properties may deliberately share a `metric` name, also across queries, as long
as they agree on help text, type and label names, and their labels keep the
series apart. Build rejects identical series within a query, and across queries
without label properties. For queries with label properties, the series depend
on the instances returned at runtime; duplicates are dropped and logged at scrape
time.
The names `windows_wmi_query_success` and `windows_wmi_query_duration_seconds`
are reserved for the collector's own metrics.
##### help
The metric `# HELP` text. Optional — if omitted, it defaults to
`windows_exporter: custom WMI metric`.
##### type
The metric type. The value can be `gauge` or `counter`. If not specified, it
defaults to `gauge`.
This key is optional.
##### labels
Labels is a map of key-value pairs that will be added as constant labels to the
metric. A constant label may not use the name of a label property.
This key is optional.
## Metrics
The wmi collector returns one metric per configured property and returned
instance, named and typed according to the configuration, plus a success and a
duration metric per query.
| Name | Description | Type | Labels |
| --- | --- | --- | --- |
| *user defined* | Numeric value of a configured WMI property | gauge / counter | *label properties*, *user defined* |
| `windows_wmi_query_success` | Whether the query ran and all of its configured properties could be read (0, 1) | gauge | `name` |
| `windows_wmi_query_duration_seconds` | Duration of the query | gauge | `name` |
A `windows_wmi_query_success` value of `0` means one of:
- The query failed, for example because the namespace, class or a property does
not exist, the `where` clause is invalid, or a label property has an
unsupported type. Nothing is exported for the query.
- The scrape timeout ran out (see [Notes](#notes)). Instances read before that are
exported.
- At least one configured property could not be converted to a number. All other
properties are still exported.
Failing queries are logged as warnings, but do not mark the whole collector as
failed: `windows_exporter_collector_success{collector="wmi"}` stays `1`, like for
the [registry](collector.registry.md) collector. Alert on
`windows_wmi_query_success` instead.
Build only validates the configuration; it does not run the queries. A class that
is missing on a host is therefore reported via `windows_wmi_query_success` rather
than preventing the exporter from starting.
### Metric naming
Each property is exported under its own metric name. You can set it explicitly
with the `metric` field, or let the collector generate one.
**Explicit name.** When `metric` is set, it is used verbatim. You are responsible
for following the Prometheus
[naming conventions](https://prometheus.io/docs/practices/naming/) (a `windows_`
prefix, and a unit suffix such as `_bytes`, `_seconds` or `_timestamp_seconds`
where applicable).
**Auto-generated name.** When `metric` is omitted, the name is assembled as:
```
windows_wmi_<query>_<property>
```
where `<query>` is the query [`name`](#name) and `<property>` is the WMI
property name. The whole string is then lowercased, every character that is not a
letter or digit is replaced with `_`, and leading/trailing `_` are trimmed.
Example:
```yaml
- name: os
class: Win32_OperatingSystem
properties:
- name: NumberOfProcesses
```
→ `windows_wmi_os_numberofprocesses`
### Example metric
For the example configuration above:
```
# HELP windows_wmi_logical_disk_free_bytes Free space on the logical disk in bytes.
# TYPE windows_wmi_logical_disk_free_bytes gauge
windows_wmi_logical_disk_free_bytes{volume="C:"} 4.20703092736e+11
windows_wmi_logical_disk_free_bytes{volume="D:"} 2.9705715712e+10
# HELP windows_wmi_logical_disk_size_bytes Size of the logical disk in bytes.
# TYPE windows_wmi_logical_disk_size_bytes gauge
windows_wmi_logical_disk_size_bytes{volume="C:"} 9.99132491776e+11
windows_wmi_logical_disk_size_bytes{volume="D:"} 4.60898430976e+11
# HELP windows_wmi_os_last_boot_timestamp_seconds Time of the last boot as a Unix timestamp.
# TYPE windows_wmi_os_last_boot_timestamp_seconds gauge
windows_wmi_os_last_boot_timestamp_seconds 1.791272732355078e+09
# HELP windows_wmi_os_numberofprocesses windows_exporter: custom WMI metric
# TYPE windows_wmi_os_numberofprocesses gauge
windows_wmi_os_numberofprocesses 478
# HELP windows_wmi_query_duration_seconds Duration of the WMI query.
# TYPE windows_wmi_query_duration_seconds gauge
windows_wmi_query_duration_seconds{name="logical_disk"} 0.0375302
windows_wmi_query_duration_seconds{name="os"} 0.1690887
# 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="logical_disk"} 1
windows_wmi_query_success{name="os"} 1
```
### Notes
- The queries run one after another and share the scrape timeout. A query that
is still running when it runs out is cancelled, and the remaining queries are
skipped; all of them report `windows_wmi_query_success` `0`. Keep queries
narrow with `where`, avoid slow providers, and check
`windows_wmi_query_duration_seconds`.
- `uint64` and `sint64` values larger than 2^53 lose precision when converted to
the 64-bit float used by Prometheus.
- WQL `ASSOCIATORS OF` and `REFERENCES OF` queries are not supported.
## Useful queries
Free space ratio per volume:
```
windows_wmi_logical_disk_free_bytes / windows_wmi_logical_disk_size_bytes
```
## Alerting examples
**prometheus.rules**
```yaml
- alert: WMIQueryFailure
expr: windows_wmi_query_success == 0
for: 15m
labels:
severity: warning
annotations:
summary: "WMI query failed (instance {{ $labels.instance }})"
description: "The WMI query {{ $labels.name }} failed for 15 minutes."
```
+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())
}
+2
View File
@@ -77,6 +77,7 @@ import (
"github.com/prometheus-community/windows_exporter/internal/collector/udp"
"github.com/prometheus-community/windows_exporter/internal/collector/update"
"github.com/prometheus-community/windows_exporter/internal/collector/vmware"
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/pdh"
"github.com/prometheus-community/windows_exporter/internal/types"
@@ -149,6 +150,7 @@ func NewWithConfig(config Config) *Collection {
collectors[udp.Name] = udp.New(&config.UDP)
collectors[update.Name] = update.New(&config.Update)
collectors[vmware.Name] = vmware.New(&config.Vmware)
collectors[wmi.Name] = wmi.New(&config.WMI)
return New(collectors)
}
+3
View File
@@ -67,6 +67,7 @@ import (
"github.com/prometheus-community/windows_exporter/internal/collector/udp"
"github.com/prometheus-community/windows_exporter/internal/collector/update"
"github.com/prometheus-community/windows_exporter/internal/collector/vmware"
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
)
type Config struct {
@@ -119,6 +120,7 @@ type Config struct {
UDP udp.Config `yaml:"udp"`
Update update.Config `yaml:"update"`
Vmware vmware.Config `yaml:"vmware"`
WMI wmi.Config `yaml:"wmi"`
}
// ConfigDefaults Is an interface to be used by the external libraries. It holds all ConfigDefaults form all collectors
@@ -175,4 +177,5 @@ var ConfigDefaults = Config{
UDP: udp.ConfigDefaults,
Update: update.ConfigDefaults,
Vmware: vmware.ConfigDefaults,
WMI: wmi.ConfigDefaults,
}
+2
View File
@@ -71,6 +71,7 @@ import (
"github.com/prometheus-community/windows_exporter/internal/collector/udp"
"github.com/prometheus-community/windows_exporter/internal/collector/update"
"github.com/prometheus-community/windows_exporter/internal/collector/vmware"
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
)
func NewBuilderWithFlags[C Collector](fn BuilderWithFlags[C]) BuilderWithFlags[Collector] {
@@ -130,6 +131,7 @@ var BuildersWithFlags = map[string]BuilderWithFlags[Collector]{
udp.Name: NewBuilderWithFlags(udp.NewWithFlags),
update.Name: NewBuilderWithFlags(update.NewWithFlags),
vmware.Name: NewBuilderWithFlags(vmware.NewWithFlags),
wmi.Name: NewBuilderWithFlags(wmi.NewWithFlags),
}
// Available returns a sorted list of available collectors.