// 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 testutils import ( "errors" "log/slog" "os" "slices" "strings" "sync" "testing" "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/collector/update" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/pdh" "github.com/prometheus-community/windows_exporter/internal/scrapecache" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus-community/windows_exporter/pkg/collector" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" ) func FuncBenchmarkCollector[C collector.Collector](b *testing.B, name string, collectFunc collector.BuilderWithFlags[C], fn ...func(app *kingpin.Application)) { b.Helper() logger := slog.New(slog.DiscardHandler) app := kingpin.New("windows_exporter", "Windows metrics exporter.") c := collectFunc(app) for _, f := range fn { f(app) } collectors := collector.New(map[string]collector.Collector{name: c}) require.NoError(b, collectors.Build(b.Context(), logger)) metrics := make(chan prometheus.Metric) var wg sync.WaitGroup wg.Go(func() { for range metrics { } }) b.Cleanup(func() { close(metrics) wg.Wait() assert.NoError(b, collectors.Close()) }) for b.Loop() { require.NoError(b, Collect(c, metrics, 0)) } } // Collect calls c.Collect within a scrape of the caches of c, as the // collection does. Calling c.Collect directly collects without the caches. func Collect(c collector.Collector, ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) (err error) { cacheScrape := scrapecache.Begin(c) defer func() { cacheScrape.End(err) }() return c.Collect(ch, maxScrapeDuration) } // TestCollector validates real Windows collector output. CI lists provisioned // collectors in WINDOWS_EXPORTER_TEST_COLLECTORS so setup failures cannot skip. func TestCollector[C collector.Collector, V any](t *testing.T, fn func(*V) C, conf *V) map[string]*dto.MetricFamily { t.Helper() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) c := fn(conf) required := slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), c.GetName()) miApp, err := mi.ApplicationInitialize() require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, miApp.Close()) }) miSession, err := miApp.NewSession(nil) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, miSession.Close()) }) t.Cleanup(func() { assert.NoError(t, c.Close()) scrapecache.Reset(c) }) // Like the collection, start without cached metrics and collect through // the caches, so the second scrape publishes cached metrics. scrapecache.Reset(c) if err := c.Build(logger, miSession); err != nil { if !required && unsupportedBuild(err) { t.Skipf("collector %s is not supported: %v", c.GetName(), err) } require.NoError(t, err, "build %s", c.GetName()) } // PDH rate counters need a second sample after initialization. time.Sleep(time.Second) var families map[string]*dto.MetricFamily for scrape := range 2 { families = collectMetrics(t, c, required) t.Logf("%s scrape %d: %d metric families", c.GetName(), scrape+1, len(families)) } return families } // unsupportedBuild returns true for errors that indicate a collector's WMI // class or PDH counter is not present on this system. MI_RESULT_INVALID_CLASS // is only accepted here (Build path) so that a mistyped class in production // code still fails Collect. Native collectors report a missing role, such as // an unconfigured failover cluster, with errors.ErrUnsupported. func unsupportedBuild(err error) bool { return errors.Is(err, mi.MI_RESULT_INVALID_CLASS) || errors.Is(err, errors.ErrUnsupported) || unsupportedCollect(err) } // unsupportedCollect returns true for Collect-path errors that indicate a // collector cannot run on this system. MI_RESULT_INVALID_CLASS is deliberately // excluded: a class missing at collect time is a real bug, not an expected skip. func unsupportedCollect(err error) bool { return errors.Is(err, mi.MI_RESULT_INVALID_NAMESPACE) || errors.Is(err, mi.MI_RESULT_INVALID_QUERY) || errors.Is(err, pdh.NewPdhError(pdh.CstatusNoCounter)) || errors.Is(err, pdh.NewPdhError(pdh.CstatusNoObject)) || errors.Is(err, pdh.ErrPerformanceCounterNotInitialized) || errors.Is(err, pdh.ErrNoData) || errors.Is(err, update.ErrUpdateServiceDisabled) || errors.Is(err, os.ErrNotExist) || errors.Is(err, windows.Errno(2151088411)) } func collectMetrics(t *testing.T, c collector.Collector, required bool) map[string]*dto.MetricFamily { t.Helper() var ( metrics collectedMetrics collectErr error ) ch := make(chan prometheus.Metric) var wg sync.WaitGroup wg.Go(func() { for metric := range ch { metrics = append(metrics, metric) } }) // Stop the receiver even if Collect panics or an assertion ends the test. func() { defer func() { close(ch) wg.Wait() }() err := Collect(c, ch, 30*time.Second) if errors.Is(err, update.ErrNoUpdates) && required { deadline := time.Now().Add(time.Minute) for errors.Is(err, update.ErrNoUpdates) && time.Now().Before(deadline) { time.Sleep(100 * time.Millisecond) err = Collect(c, ch, 30*time.Second) } } collectErr = err }() // Empty optional instance groups may report no data alongside valid metrics. // Inspect every joined error so an unrelated failure cannot be hidden. if noDataOnly(collectErr) && len(metrics) > 0 { t.Logf("collector %s has empty optional instance groups: %v", c.GetName(), collectErr) collectErr = nil } if !required && (unsupportedCollect(collectErr) || errors.Is(collectErr, update.ErrNoUpdates)) { t.Skipf("collector %s is not supported: %v", c.GetName(), collectErr) } require.NoError(t, collectErr, "collect %s", c.GetName()) if required { require.NotEmpty(t, metrics, "provisioned collector %s emitted no metrics", c.GetName()) } registry := prometheus.NewPedanticRegistry() require.NoError(t, registry.Register(metrics)) gathered, err := registry.Gather() require.NoError(t, err, "invalid metrics from %s", c.GetName()) families := make(map[string]*dto.MetricFamily, len(gathered)) for _, family := range gathered { families[family.GetName()] = family if strings.HasSuffix(family.GetName(), "_collector_success") { for _, metric := range family.GetMetric() { require.InDelta(t, 1, metric.GetGauge().GetValue(), 0, "failed child collector: %s", metric) } } } return families } type collectedMetrics []prometheus.Metric func (m collectedMetrics) Describe(ch chan<- *prometheus.Desc) { prometheus.DescribeByCollect(m, ch) } func (m collectedMetrics) Collect(ch chan<- prometheus.Metric) { for _, metric := range m { ch <- metric } } // RequireFixtureMetric checks a known CI fixture without requiring it on a // developer's machine. Labels match exactly, ignoring case for Windows names. func RequireFixtureMetric(t *testing.T, families map[string]*dto.MetricFamily, collectorName, metricName string, labels prometheus.Labels) *dto.Metric { t.Helper() if !slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), collectorName) { return nil } require.Contains(t, families, metricName) for _, metric := range families[metricName].GetMetric() { matched := true for name, value := range labels { found := false for _, label := range metric.GetLabel() { if label.GetName() == name && strings.EqualFold(label.GetValue(), value) { found = true break } } matched = matched && found } if matched { return metric } } t.Fatalf("metric %s with fixture labels %v was not emitted; got %s", metricName, labels, families[metricName]) return nil } // MetricValuesByLabel returns the gauge or counter values of metricName, // keyed by the value of the label labelName. func MetricValuesByLabel(families map[string]*dto.MetricFamily, metricName, labelName string) map[string]float64 { values := make(map[string]float64) for _, metric := range families[metricName].GetMetric() { for _, label := range metric.GetLabel() { if label.GetName() != labelName { continue } if metric.GetCounter() != nil { values[label.GetValue()] = metric.GetCounter().GetValue() } else { values[label.GetValue()] = metric.GetGauge().GetValue() } } } return values } func noDataOnly(err error) bool { if joined, ok := err.(interface{ Unwrap() []error }); ok { for _, cause := range joined.Unwrap() { if !noDataOnly(cause) { return false } } return true } if wrapped, ok := err.(interface{ Unwrap() error }); ok { return noDataOnly(wrapped.Unwrap()) } return errors.Is(err, pdh.ErrNoData) || errors.Is(err, types.ErrNoData) }