diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go index 81fdd8f4ff..0a4c76e0fb 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" "github.com/prometheus/common/expfmt" @@ -162,7 +163,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.") @@ -310,12 +311,31 @@ 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": p = &jsonPrinter{} case "promql": p = &promqlPrinter{} + case "unittest": + p = &unittestPrinter{Step: model.Duration(queryRange.Step)} } if httpConfigFilePath != "" { @@ -372,13 +392,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)) @@ -390,7 +410,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 @@ -1214,6 +1234,64 @@ 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{} + } + + if samplePair.Histogram != nil { + panic("histograms are not supported") + } + + 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{} + } + + 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)) + } + } + } + + inputSeries := make([]series, 0, len(samples)) + + for metric, value := range samples { + inputSeries = append(inputSeries, series{Series: metric, Values: strings.Join(value, " ")}) + } + + 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 { diff --git a/cmd/promtool/main_test.go b/cmd/promtool/main_test.go index 5d36a66fcd..5723b47b43 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 ab675e6345..25d9d3cbaf 100644 --- a/docs/command-line/promtool.md +++ b/docs/command-line/promtool.md @@ -215,7 +215,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. | |