From cde61858a7bab629c16eeb3c80edd9c409e2e8f8 Mon Sep 17 00:00:00 2001 From: Brad Beam Date: Tue, 6 Dec 2022 12:43:26 -0600 Subject: [PATCH 1/2] feat: Add support for `unittest` output for promtool queries This adds support for a new output type `unittest` to make it easier to seed unit test input series values from real data. Signed-off-by: Brad Beam --- cmd/promtool/main.go | 56 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go index b52fe7cdbb..bd7eef6f2a 100644 --- a/cmd/promtool/main.go +++ b/cmd/promtool/main.go @@ -152,7 +152,7 @@ func main() { agentMode := checkConfigCmd.Flag("agent", "Check config file for Prometheus in Agent mode.").Bool() queryCmd := app.Command("query", "Run query against a Prometheus server.") - queryCmdFmt := queryCmd.Flag("format", "Output format of the query.").Short('o').Default("promql").Enum("promql", "json") + queryCmdFmt := queryCmd.Flag("format", "Output format of the query: promql, json, unittest. Unittest output is experimental.").Short('o').Default("promql").Enum("promql", "json", "unittest") queryCmd.Flag("http.config.file", "HTTP client configuration file for promtool to connect to Prometheus.").PlaceHolder("").ExistingFileVar(&httpConfigFilePath) queryInstantCmd := queryCmd.Command("instant", "Run instant query.") @@ -305,6 +305,9 @@ func main() { p = &jsonPrinter{} case "promql": p = &promqlPrinter{} + case "unittest": + // TODO pick out default value used in query range (L892) + p = &unittestPrinter{Step: model.Duration(*queryRangeStep)} } if httpConfigFilePath != "" { @@ -1149,6 +1152,57 @@ func (j *jsonPrinter) printLabelValues(v model.LabelValues) { json.NewEncoder(os.Stdout).Encode(v) } +type unittestPrinter struct { + Step model.Duration +} + +func (u *unittestPrinter) printValue(v model.Value) { + samples := make(map[string][]string) + + switch modelType := v.(type) { + case model.Vector: + + for _, samplePair := range modelType { + metricName := samplePair.Metric.String() + + if _, ok := samples[metricName]; !ok { + samples[metricName] = []string{} + } + + samples[metricName] = append(samples[metricName], strconv.FormatFloat(float64(samplePair.Value), 'f', -1, 64)) + } + + case model.Matrix: + for _, stream := range modelType { + metricName := stream.Metric.String() + + if _, ok := samples[metricName]; !ok { + samples[metricName] = []string{} + } + + for _, samplePair := range stream.Values { + samples[metricName] = append(samples[metricName], strconv.FormatFloat(float64(samplePair.Value), 'f', -1, 64)) + } + } + } + + inputSeries := make([]series, 0, len(samples)) + + for metric, value := range samples { + inputSeries = append(inputSeries, series{Series: metric, Values: strings.Join(value, " ")}) + } + + // TODO do we want to use `yaml.FutureLineWrap()` + if err := yaml.NewEncoder(os.Stdout).Encode(unitTestFile{Tests: []testGroup{{Interval: u.Step, InputSeries: inputSeries}}}); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(failureExitCode) + } +} + +func (u *unittestPrinter) printSeries(v []model.LabelSet) {} + +func (u *unittestPrinter) printLabelValues(v model.LabelValues) {} + // importRules backfills recording rules from the files provided. The output are blocks of data // at the outputDir location. func importRules(url *url.URL, roundTripper http.RoundTripper, start, end, outputDir string, evalInterval, maxBlockDuration time.Duration, files ...string) error { From 9eaa7cda7a6eca4c6ce7d8e4b241265a25b021ec Mon Sep 17 00:00:00 2001 From: Brad Beam Date: Tue, 17 Dec 2024 23:30:33 -0600 Subject: [PATCH 2/2] refactor: Change to using v1.Range for passing start,end,step parameters around This refactor was introduced to handle parsing query range externally. Since the printer wanted to account for the various query functions, it made sense to include them in with this change. Signed-off-by: Brad Beam --- cmd/promtool/main.go | 38 ++++++++++--- cmd/promtool/main_test.go | 11 +++- cmd/promtool/query.go | 100 +++++++++++++--------------------- docs/command-line/promtool.md | 2 +- 4 files changed, 78 insertions(+), 73 deletions(-) diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go index bd7eef6f2a..d85aa8082d 100644 --- a/cmd/promtool/main.go +++ b/cmd/promtool/main.go @@ -34,6 +34,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/google/pprof/profile" "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil/promlint" config_util "github.com/prometheus/common/config" @@ -299,6 +300,23 @@ func main() { parsedCmd := kingpin.MustParse(app.Parse(os.Args[1:])) + queryRange := v1.Range{} + var err error + + switch parsedCmd { + case queryInstantCmd.FullCommand(): + queryRange, err = newQueryRange(*queryInstantTime, "", time.Second) + case queryRangeCmd.FullCommand(): + queryRange, err = newQueryRange(*queryRangeBegin, *queryRangeEnd, *queryRangeStep) + case querySeriesCmd.FullCommand(): + queryRange, err = newQueryRange(*querySeriesBegin, *querySeriesEnd, 0) + case queryLabelsCmd.FullCommand(): + queryRange, err = newQueryRange(*queryLabelsBegin, *queryLabelsEnd, 0) + } + if err != nil { + kingpin.Fatalf("Failed to parse query range: %v", err) + } + var p printer switch *queryCmdFmt { case "json": @@ -306,8 +324,7 @@ func main() { case "promql": p = &promqlPrinter{} case "unittest": - // TODO pick out default value used in query range (L892) - p = &unittestPrinter{Step: model.Duration(*queryRangeStep)} + p = &unittestPrinter{Step: model.Duration(queryRange.Step)} } if httpConfigFilePath != "" { @@ -364,13 +381,13 @@ func main() { os.Exit(PushMetrics(remoteWriteURL, httpRoundTripper, *pushMetricsHeaders, *pushMetricsTimeout, *pushMetricsLabels, *metricFiles...)) case queryInstantCmd.FullCommand(): - os.Exit(QueryInstant(serverURL, httpRoundTripper, *queryInstantExpr, *queryInstantTime, p)) + os.Exit(QueryInstant(serverURL, httpRoundTripper, *queryInstantExpr, queryRange, p)) case queryRangeCmd.FullCommand(): - os.Exit(QueryRange(serverURL, httpRoundTripper, *queryRangeHeaders, *queryRangeExpr, *queryRangeBegin, *queryRangeEnd, *queryRangeStep, p)) + os.Exit(QueryRange(serverURL, httpRoundTripper, *queryRangeHeaders, *queryRangeExpr, queryRange, p)) case querySeriesCmd.FullCommand(): - os.Exit(QuerySeries(serverURL, httpRoundTripper, *querySeriesMatch, *querySeriesBegin, *querySeriesEnd, p)) + os.Exit(QuerySeries(serverURL, httpRoundTripper, *querySeriesMatch, queryRange, p)) case debugPprofCmd.FullCommand(): os.Exit(debugPprof(*debugPprofServer)) @@ -382,7 +399,7 @@ func main() { os.Exit(debugAll(*debugAllServer)) case queryLabelsCmd.FullCommand(): - os.Exit(QueryLabels(serverURL, httpRoundTripper, *queryLabelsMatch, *queryLabelsName, *queryLabelsBegin, *queryLabelsEnd, p)) + os.Exit(QueryLabels(serverURL, httpRoundTripper, *queryLabelsMatch, *queryLabelsName, queryRange, p)) case testRulesCmd.FullCommand(): results := io.Discard @@ -1169,6 +1186,10 @@ func (u *unittestPrinter) printValue(v model.Value) { samples[metricName] = []string{} } + if samplePair.Histogram != nil { + panic("histograms are not supported") + } + samples[metricName] = append(samples[metricName], strconv.FormatFloat(float64(samplePair.Value), 'f', -1, 64)) } @@ -1180,6 +1201,10 @@ func (u *unittestPrinter) printValue(v model.Value) { samples[metricName] = []string{} } + if len(stream.Histograms) > 0 { + panic("histograms are not supported") + } + for _, samplePair := range stream.Values { samples[metricName] = append(samples[metricName], strconv.FormatFloat(float64(samplePair.Value), 'f', -1, 64)) } @@ -1192,7 +1217,6 @@ func (u *unittestPrinter) printValue(v model.Value) { inputSeries = append(inputSeries, series{Series: metric, Values: strings.Join(value, " ")}) } - // TODO do we want to use `yaml.FutureLineWrap()` if err := yaml.NewEncoder(os.Stdout).Encode(unitTestFile{Tests: []testGroup{{Interval: u.Step, InputSeries: inputSeries}}}); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(failureExitCode) diff --git a/cmd/promtool/main_test.go b/cmd/promtool/main_test.go index 9a07269188..f187c00b71 100644 --- a/cmd/promtool/main_test.go +++ b/cmd/promtool/main_test.go @@ -68,14 +68,17 @@ func TestQueryRange(t *testing.T) { require.NoError(t, err) p := &promqlPrinter{} - exitCode := QueryRange(urlObject, http.DefaultTransport, map[string]string{}, "up", "0", "300", 0, p) + qr, err := newQueryRange("0", "300", 0) + require.NoError(t, err) + exitCode := QueryRange(urlObject, http.DefaultTransport, map[string]string{}, "up", qr, p) require.Equal(t, "/api/v1/query_range", getRequest().URL.Path) form := getRequest().Form require.Equal(t, "up", form.Get("query")) require.Equal(t, "1", form.Get("step")) require.Equal(t, 0, exitCode) - exitCode = QueryRange(urlObject, http.DefaultTransport, map[string]string{}, "up", "0", "300", 10*time.Millisecond, p) + qr, err = newQueryRange("0", "300", 10*time.Millisecond) + exitCode = QueryRange(urlObject, http.DefaultTransport, map[string]string{}, "up", qr, p) require.Equal(t, "/api/v1/query_range", getRequest().URL.Path) form = getRequest().Form require.Equal(t, "up", form.Get("query")) @@ -92,7 +95,9 @@ func TestQueryInstant(t *testing.T) { require.NoError(t, err) p := &promqlPrinter{} - exitCode := QueryInstant(urlObject, http.DefaultTransport, "up", "300", p) + qr, err := newQueryRange("300", "", time.Second) + require.NoError(t, err) + exitCode := QueryInstant(urlObject, http.DefaultTransport, "up", qr, p) require.Equal(t, "/api/v1/query", getRequest().URL.Path) form := getRequest().Form require.Equal(t, "up", form.Get("query")) diff --git a/cmd/promtool/query.go b/cmd/promtool/query.go index 0d7cb12cf4..b0bd592f8d 100644 --- a/cmd/promtool/query.go +++ b/cmd/promtool/query.go @@ -61,25 +61,20 @@ func newAPI(url *url.URL, roundTripper http.RoundTripper, headers map[string]str } // QueryInstant performs an instant query against a Prometheus server. -func QueryInstant(url *url.URL, roundTripper http.RoundTripper, query, evalTime string, p printer) int { +func QueryInstant(url *url.URL, roundTripper http.RoundTripper, query string, qr v1.Range, p printer) int { api, err := newAPI(url, roundTripper, nil) if err != nil { fmt.Fprintln(os.Stderr, "error creating API client:", err) return failureExitCode } - eTime := time.Now() - if evalTime != "" { - eTime, err = parseTime(evalTime) - if err != nil { - fmt.Fprintln(os.Stderr, "error parsing evaluation time:", err) - return failureExitCode - } + if qr.Start.Equal(minTime) { + qr.Start = time.Now() } // Run query against client. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - val, _, err := api.Query(ctx, query, eTime) // Ignoring warnings for now. + val, _, err := api.Query(ctx, query, qr.Start) // Ignoring warnings for now. cancel() if err != nil { return handleAPIError(err) @@ -91,50 +86,24 @@ func QueryInstant(url *url.URL, roundTripper http.RoundTripper, query, evalTime } // QueryRange performs a range query against a Prometheus server. -func QueryRange(url *url.URL, roundTripper http.RoundTripper, headers map[string]string, query, start, end string, step time.Duration, p printer) int { +func QueryRange(url *url.URL, roundTripper http.RoundTripper, headers map[string]string, query string, qr v1.Range, p printer) int { api, err := newAPI(url, roundTripper, headers) if err != nil { fmt.Fprintln(os.Stderr, "error creating API client:", err) return failureExitCode } - var stime, etime time.Time - - if end == "" { - etime = time.Now() - } else { - etime, err = parseTime(end) - if err != nil { - fmt.Fprintln(os.Stderr, "error parsing end time:", err) - return failureExitCode - } + if qr.End.Equal(maxTime) { + qr.End = time.Now() } - if start == "" { - stime = etime.Add(-5 * time.Minute) - } else { - stime, err = parseTime(start) - if err != nil { - fmt.Fprintln(os.Stderr, "error parsing start time:", err) - return failureExitCode - } - } - - if !stime.Before(etime) { - fmt.Fprintln(os.Stderr, "start time is not before end time") - return failureExitCode - } - - if step == 0 { - resolution := math.Max(math.Floor(etime.Sub(stime).Seconds()/250), 1) - // Convert seconds to nanoseconds such that time.Duration parses correctly. - step = time.Duration(resolution) * time.Second + if qr.Start.Equal(minTime) { + qr.Start = qr.End.Add(-5 * time.Minute) } // Run query against client. - r := v1.Range{Start: stime, End: etime, Step: step} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - val, _, err := api.QueryRange(ctx, query, r) // Ignoring warnings for now. + val, _, err := api.QueryRange(ctx, query, qr) // Ignoring warnings for now. cancel() if err != nil { @@ -146,22 +115,16 @@ func QueryRange(url *url.URL, roundTripper http.RoundTripper, headers map[string } // QuerySeries queries for a series against a Prometheus server. -func QuerySeries(url *url.URL, roundTripper http.RoundTripper, matchers []string, start, end string, p printer) int { +func QuerySeries(url *url.URL, roundTripper http.RoundTripper, matchers []string, qr v1.Range, p printer) int { api, err := newAPI(url, roundTripper, nil) if err != nil { fmt.Fprintln(os.Stderr, "error creating API client:", err) return failureExitCode } - stime, etime, err := parseStartTimeAndEndTime(start, end) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return failureExitCode - } - // Run query against client. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - val, _, err := api.Series(ctx, matchers, stime, etime) // Ignoring warnings for now. + val, _, err := api.Series(ctx, matchers, qr.Start, qr.End) // Ignoring warnings for now. cancel() if err != nil { @@ -173,22 +136,16 @@ func QuerySeries(url *url.URL, roundTripper http.RoundTripper, matchers []string } // QueryLabels queries for label values against a Prometheus server. -func QueryLabels(url *url.URL, roundTripper http.RoundTripper, matchers []string, name, start, end string, p printer) int { +func QueryLabels(url *url.URL, roundTripper http.RoundTripper, matchers []string, name string, qr v1.Range, p printer) int { api, err := newAPI(url, roundTripper, nil) if err != nil { fmt.Fprintln(os.Stderr, "error creating API client:", err) return failureExitCode } - stime, etime, err := parseStartTimeAndEndTime(start, end) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return failureExitCode - } - // Run query against client. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - val, warn, err := api.LabelValues(ctx, name, matchers, stime, etime) + val, warn, err := api.LabelValues(ctx, name, matchers, qr.Start, qr.End) cancel() for _, v := range warn { @@ -213,12 +170,31 @@ func handleAPIError(err error) int { return failureExitCode } +func newQueryRange(start, end string, step time.Duration) (v1.Range, error) { + qr := v1.Range{} + var err error + qr.Start, qr.End, err = parseStartTimeAndEndTime(start, end) + + if !qr.Start.Before(qr.End) { + return qr, errors.New("start time is not before end time") + } + + if step == 0 { + resolution := math.Max(math.Floor(qr.End.Sub(qr.Start).Seconds()/250), 1) + // Convert seconds to nanoseconds such that time.Duration parses correctly. + step = time.Duration(resolution) * time.Second + } + + qr.Step = step + + return qr, err +} + +var minTime = time.Now().Add(-9999 * time.Hour) +var maxTime = time.Now().Add(9999 * time.Hour) + func parseStartTimeAndEndTime(start, end string) (time.Time, time.Time, error) { - var ( - minTime = time.Now().Add(-9999 * time.Hour) - maxTime = time.Now().Add(9999 * time.Hour) - err error - ) + var err error stime := minTime etime := maxTime diff --git a/docs/command-line/promtool.md b/docs/command-line/promtool.md index 5e2a8f6bb1..368e81a0c9 100644 --- a/docs/command-line/promtool.md +++ b/docs/command-line/promtool.md @@ -212,7 +212,7 @@ Run query against a Prometheus server. | Flag | Description | Default | | --- | --- | --- | -| -o, --format | Output format of the query. | `promql` | +| -o, --format | Output format of the query: promql, json, unittest. Unittest output is experimental. | `promql` | | --http.config.file | HTTP client configuration file for promtool to connect to Prometheus. | |