diff --git a/README.md b/README.md index b28c5359..8bb01501 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ windows_exporter accepts flags to configure certain behaviours. The ones configu | `--telemetry.path` | URL path for surfacing collected metrics. | `/metrics` | | `--collectors.enabled` | Comma-separated list of collectors to use. Use `[defaults]` as a placeholder which gets expanded containing all the collectors enabled by default. | `[defaults]` | | `--scrape.timeout-margin` | Seconds to subtract from the timeout allowed by the client. The margin takes at most half of the client's timeout. Tune to allow for overhead or high loads. | `0.5` | +| `--scrape.stdout` | Collect metrics once and write them to stdout, then exit. Logs go to stderr unless `--log.file` is set. PDH rate counters cover only a short interval on the first scrape after startup. | `false` | | `--web.config.file` | A [web config][web_config] for setting up TLS and Auth | None | | `--config.file` | [Using a config file](#using-a-configuration-file) from path | None | | `--log.file` | Output file of log messages. One of [stdout, stderr, eventlog, \]
**NOTE:** The MSI installer will add a default argument to the installed service setting this to eventlog | stderr | diff --git a/cmd/windows_exporter/main.go b/cmd/windows_exporter/main.go index 24ca3191..e12e2405 100644 --- a/cmd/windows_exporter/main.go +++ b/cmd/windows_exporter/main.go @@ -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 { diff --git a/cmd/windows_exporter/main_test.go b/cmd/windows_exporter/main_test.go index a9ffc5b7..e5b22cb2 100644 --- a/cmd/windows_exporter/main_test.go +++ b/cmd/windows_exporter/main_test.go @@ -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() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ed58ea76..999c1fbf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -158,7 +158,9 @@ func TestNewConfigFileResolverFlags(t *testing.T) { // selects the configuration file itself flag.Name == "config.file", // prints the available sub-collectors and exits - flag.Name == "collector.exchange.list": + flag.Name == "collector.exchange.list", + // CLI-only diagnostic mode; not valid in a config file + flag.Name == "scrape.stdout": continue }