cmd: add --scrape.stdout one-shot mode (#2705)

Signed-off-by: Marc-André Lureau <marcandre.lureau@redhat.com>
This commit is contained in:
Marc-André Lureau
2026-10-13 16:44:34 +02:00
committed by GitHub
parent 8f118226f7
commit 5d6cf525bf
4 changed files with 102 additions and 5 deletions
+66 -4
View File
@@ -23,10 +23,12 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"math"
"net/http"
"net/http/httptest"
"net/http/pprof"
"os"
"os/signal"
@@ -93,6 +95,12 @@ func run(ctx context.Context, args []string) int {
app := kingpin.New("windows_exporter", "A metrics collector for Windows.")
var (
scrapeStdout = app.Flag(
"scrape.stdout",
"Collect metrics once and write them to stdout in Prometheus text format, then exit. "+
"Logs are sent to stderr unless --log.file is set explicitly. "+
"PDH rate counters need two samples; the first scrape after startup covers only a short interval.",
).Default("false").Bool()
configFile = app.Flag(
"config.file",
"YAML configuration file to use. Values set in this file will be overridden by CLI flags.",
@@ -160,6 +168,13 @@ func run(ctx context.Context, args []string) int {
return 1
}
// When scraping to stdout, redirect logs to stderr so they don't
// mix with the metrics output. Only override if the user hasn't
// explicitly set --log.file to something other than the default.
if *scrapeStdout && logFile.String() == "stdout" {
_ = logFile.Set("stderr")
}
logger, err := log.New(logConfig)
if err != nil {
logStartupError(ctx, "failed to create logger", err)
@@ -233,12 +248,18 @@ func run(ctx context.Context, args []string) int {
logger.InfoContext(ctx, "Enabled collectors: "+strings.Join(effectiveCollectors(enabledCollectorList, disabledCollectorList), ", "))
mux := http.NewServeMux()
mux.Handle("GET /health", httphandler.NewHealthHandler())
mux.Handle("GET "+*metricsPath, httphandler.New(logger, collectors, &httphandler.Options{
handlerOpts := &httphandler.Options{
DisableExporterMetrics: *disableExporterMetrics,
TimeoutMargin: *timeoutMargin,
}))
}
if *scrapeStdout {
return scrapeToStdout(ctx, os.Stdout, logger, collectors, handlerOpts)
}
mux := http.NewServeMux()
mux.Handle("GET /health", httphandler.NewHealthHandler())
mux.Handle("GET "+*metricsPath, httphandler.New(logger, collectors, handlerOpts))
if *debugEnabled {
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
@@ -405,6 +426,47 @@ func expandEnabledCollectors(enabled string) []string {
return slices.Compact(strings.Split(expanded, ","))
}
// scrapeToStdout performs a single scrape through the HTTP handler and writes
// the response body to stdout. It reuses the same handler that serves /metrics
// so the output is identical, including exporter self-instrumentation and
// timeout margin.
func scrapeToStdout(ctx context.Context, w io.Writer, logger *slog.Logger, collectors *collector.Collection, opts *httphandler.Options) int {
handler := httphandler.New(logger, collectors, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
logger.LogAttrs(ctx, slog.LevelError, "couldn't create request",
slog.Any("err", err),
)
return 1
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
result := rec.Result()
defer result.Body.Close()
if result.StatusCode != http.StatusOK {
logger.LogAttrs(ctx, slog.LevelError, "scrape failed",
slog.Int("status", result.StatusCode),
)
return 1
}
if _, err := io.Copy(w, result.Body); err != nil {
logger.LogAttrs(ctx, slog.LevelError, "failed to write metrics to stdout",
slog.Any("err", err),
)
return 1
}
return 0
}
// setProcessMemoryLimit applies the CLI contract: zero disables the soft limit.
func setProcessMemoryLimit(limit int64) error {
if limit < 0 {
+32
View File
@@ -18,6 +18,7 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
@@ -31,6 +32,9 @@ import (
"testing"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/httphandler"
"github.com/prometheus-community/windows_exporter/pkg/collector"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
@@ -180,6 +184,34 @@ func waitUntilListening(tb testing.TB, network, address string) error {
return fmt.Errorf("listener not listening: %w", err)
}
func TestScrapeStdout(t *testing.T) {
t.Parallel()
logger := slog.New(slog.DiscardHandler)
collectors := collector.NewWithFlags(kingpin.New("test", "test"))
require.NoError(t, collectors.Enable([]string{"cpu"}))
require.NoError(t, collectors.Build(t.Context(), logger))
t.Cleanup(func() {
require.NoError(t, collectors.Close())
})
var buf bytes.Buffer
exitCode := scrapeToStdout(t.Context(), &buf, logger, collectors, &httphandler.Options{})
require.Equal(t, 0, exitCode)
output := buf.String()
// Must contain collector metrics.
require.Contains(t, output, "windows_cpu_time_total")
// Must contain exporter build info (proves the HTTP handler path is used).
require.Contains(t, output, "windows_exporter_build_info")
// Must contain collector success metadata.
require.Contains(t, output, `windows_exporter_collector_success{collector="cpu"} 1`)
}
func TestSetPriorityWindows(t *testing.T) {
t.Parallel()