Files
windows_exporter/internal/collector/wmi/wmi_output_test.go
T

396 lines
13 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()
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)
})
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()
}