mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-18 10:39:05 +02:00
415 lines
14 KiB
Go
415 lines
14 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// Copyright The Prometheus Authors
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//go:build windows
|
|
|
|
package wmi_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()
|
|
|
|
return newScraper(t, c, maxScrapeDuration)()
|
|
}
|
|
|
|
// newScraper builds c against a live MI session and returns a function that
|
|
// scrapes it, so that a test can scrape the same collector more than once.
|
|
func newScraper(t *testing.T, c *wmi.Collector, maxScrapeDuration time.Duration) func() string {
|
|
t.Helper()
|
|
|
|
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
|
|
|
|
reg := prometheus.NewRegistry()
|
|
reg.MustRegister(collectorAdapter{c, maxScrapeDuration})
|
|
|
|
handler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError})
|
|
|
|
return func() string {
|
|
rw := httptest.NewRecorder()
|
|
handler.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.
|
|
//
|
|
// The collector is scraped twice: the second scrape reuses the label pairs
|
|
// cached by the first and must produce the same output.
|
|
func TestCollectorMetrics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
next := newScraper(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"}},
|
|
},
|
|
},
|
|
}), 5*time.Second)
|
|
|
|
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{query="kernel"} 1
|
|
windows_wmi_query_success{query="missing_class"} 0
|
|
windows_wmi_query_success{query="os"} 1
|
|
windows_wmi_query_success{query="partial"} 0
|
|
windows_wmi_query_success{query="shared_a"} 1
|
|
windows_wmi_query_success{query="shared_b"} 1
|
|
windows_wmi_query_success{query="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
|
|
`
|
|
|
|
for range 2 {
|
|
got := next()
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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\{query="`+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{query="a"} 0`)
|
|
require.Contains(t, got, `windows_wmi_query_success{query="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)
|
|
})
|
|
|
|
t.Run("unknown field", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := kingpin.New("test", "")
|
|
_ = wmi.NewWithFlags(app)
|
|
|
|
_, err := app.Parse([]string{"--collector.wmi.queries", `[{"name":"os","class":"Win32_OperatingSystem","label_property":[{"name":"Name"}],"properties":[{"name":"Primary"}]}]`})
|
|
require.ErrorContains(t, err, "label_property")
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
}
|