mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
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:
co-authored by
Claude Opus 5.5
parent
a67a64c55c
commit
88034b818e
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user