diff --git a/web/api/api.go b/web/api/api.go index 44d599d1da..32c3c375a2 100644 --- a/web/api/api.go +++ b/web/api/api.go @@ -19,11 +19,13 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/prometheus/storage/local" + "github.com/prometheus/prometheus/utility" "github.com/prometheus/prometheus/web/httputils" ) // MetricsService manages the /api HTTP endpoint. type MetricsService struct { + nower utility.Time Storage local.Storage } diff --git a/web/api/api_test.go b/web/api/api_test.go new file mode 100644 index 0000000000..bfcf598cf0 --- /dev/null +++ b/web/api/api_test.go @@ -0,0 +1,144 @@ +// Copyright 2015 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. + +package api + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" + + clientmodel "github.com/prometheus/client_golang/model" + + "github.com/prometheus/prometheus/storage/local" + "github.com/prometheus/prometheus/utility" +) + +type testInstantProvider struct { + now time.Time +} + +func (p testInstantProvider) Now() time.Time { + return p.now +} + +// This is a bit annoying. On one hand, we have to choose a current timestamp +// because the storage doesn't have a mocked-out time yet and would otherwise +// immediately throw away "old" samples. On the other hand, we have to make +// sure that the float value survives the parsing and re-formatting in the +// query layer precisely without any change. Thus we round to seconds and then +// add known-good digits after the decimal point which behave well in +// parsing/re-formatting. +var testTimestamp = clientmodel.TimestampFromTime(time.Now().Round(time.Second)).Add(124 * time.Millisecond) + +var testNower = utility.Time{ + Provider: testInstantProvider{ + now: testTimestamp.Time(), + }, +} + +func TestQuery(t *testing.T) { + scenarios := []struct { + // URL query string. + queryStr string + // Expected HTTP response status code. + status int + // Regex to match against response body. + bodyRe string + }{ + { + queryStr: "", + status: http.StatusOK, + bodyRe: "syntax error", + }, + { + queryStr: "expr=testmetric", + status: http.StatusOK, + bodyRe: `{"type":"vector","value":\[\{"metric":{"__name__":"testmetric"},"value":"0","timestamp":\d+\.\d+}\],"version":1\}`, + }, + { + queryStr: "expr=testmetric×tamp=" + testTimestamp.String(), + status: http.StatusOK, + bodyRe: `{"type":"vector","value":\[\{"metric":{"__name__":"testmetric"},"value":"0","timestamp":` + testTimestamp.String() + `}\],"version":1\}`, + }, + { + queryStr: "expr=testmetric×tamp=" + testTimestamp.Add(-time.Hour).String(), + status: http.StatusOK, + bodyRe: `{"type":"vector","value":\[\],"version":1\}`, + }, + { + queryStr: "timestamp=invalid", + status: http.StatusBadRequest, + bodyRe: "invalid query timestamp", + }, + { + queryStr: "expr=(badexpression", + status: http.StatusOK, + bodyRe: "syntax error", + }, + } + + storage, closer := local.NewTestStorage(t, 1) + defer closer.Close() + storage.Append(&clientmodel.Sample{ + Metric: clientmodel.Metric{ + clientmodel.MetricNameLabel: "testmetric", + }, + Timestamp: testTimestamp, + Value: 0, + }) + storage.WaitForIndexing() + + api := MetricsService{ + Storage: storage, + } + api.RegisterHandler() + + server := httptest.NewServer(http.DefaultServeMux) + defer server.Close() + + for i, s := range scenarios { + // Do query. + resp, err := http.Get(server.URL + "/api/query?" + s.queryStr) + if err != nil { + t.Fatalf("%d. Error querying API: %s", i, err) + } + + // Check status code. + if resp.StatusCode != s.status { + t.Fatalf("%d. Unexpected status code; got %d, want %d", i, resp.StatusCode, s.status) + } + + // Check response headers. + ct := resp.Header["Content-Type"] + if len(ct) != 1 { + t.Fatalf("%d. Unexpected number of 'Content-Type' headers; got %d, want 1", i, len(ct)) + } + if ct[0] != "application/json" { + t.Fatalf("%d. Unexpected 'Content-Type' header; got %s; want %s", i, ct[0], "application/json") + } + + // Check body. + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("%d. Error reading response body: %s", i, err) + } + re := regexp.MustCompile(s.bodyRe) + if !re.Match(b) { + t.Fatalf("%d. Body didn't match '%s'. Body: %s", i, s.bodyRe, string(b)) + } + } +} diff --git a/web/api/query.go b/web/api/query.go index ae539dee85..8cae6884c4 100644 --- a/web/api/query.go +++ b/web/api/query.go @@ -29,6 +29,7 @@ import ( "github.com/prometheus/prometheus/rules" "github.com/prometheus/prometheus/rules/ast" "github.com/prometheus/prometheus/stats" + "github.com/prometheus/prometheus/utility" "github.com/prometheus/prometheus/web/httputils" ) @@ -46,9 +47,9 @@ func httpJSONError(w http.ResponseWriter, err error, code int) { fmt.Fprintln(w, ast.ErrorToJSON(err)) } -func parseTimestampOrNow(t string) (clientmodel.Timestamp, error) { +func parseTimestampOrNow(t string, nower utility.Time) (clientmodel.Timestamp, error) { if t == "" { - return clientmodel.Now(), nil + return clientmodel.TimestampFromTime(nower.Now()), nil } tFloat, err := strconv.ParseFloat(t, 64) @@ -74,7 +75,7 @@ func (serv MetricsService) Query(w http.ResponseWriter, r *http.Request) { params := httputils.GetQueryParams(r) expr := params.Get("expr") - timestamp, err := parseTimestampOrNow(params.Get("timestamp")) + timestamp, err := parseTimestampOrNow(params.Get("timestamp"), serv.nower) if err != nil { httpJSONError(w, fmt.Errorf("invalid query timestamp %s", err), http.StatusBadRequest) return @@ -112,7 +113,7 @@ func (serv MetricsService) QueryRange(w http.ResponseWriter, r *http.Request) { return } - end, err := parseTimestampOrNow(params.Get("end")) + end, err := parseTimestampOrNow(params.Get("end"), serv.nower) if err != nil { httpJSONError(w, fmt.Errorf("invalid query timestamp: %s", err), http.StatusBadRequest) return @@ -122,7 +123,7 @@ func (serv MetricsService) QueryRange(w http.ResponseWriter, r *http.Request) { // the current time as the end time. Instead, the "end" parameter should // simply be omitted or set to an empty string for that case. if end == 0 { - end = clientmodel.Now() + end = clientmodel.TimestampFromTime(serv.nower.Now()) } exprNode, err := rules.LoadExprFromString(expr) diff --git a/web/api/query_test.go b/web/api/query_test.go index b311180df6..ce691eda06 100644 --- a/web/api/query_test.go +++ b/web/api/query_test.go @@ -21,16 +21,16 @@ import ( ) func TestParseTimestampOrNow(t *testing.T) { - ts, err := parseTimestampOrNow("") + ts, err := parseTimestampOrNow("", testNower) if err != nil { t.Fatalf("err = %s; want nil", err) } - now := clientmodel.Now() - if now.Sub(ts) > time.Second || now.Sub(ts) < 0 { - t.Fatalf("ts = %v; want %v <= ts <= %v", ts, now.Sub(ts), now) + now := clientmodel.TimestampFromTime(testNower.Now()) + if !now.Equal(ts) { + t.Fatalf("ts = %v; want ts = %v", ts, now) } - ts, err = parseTimestampOrNow("1426956073.12345") + ts, err = parseTimestampOrNow("1426956073.12345", testNower) if err != nil { t.Fatalf("err = %s; want nil", err) } @@ -39,7 +39,7 @@ func TestParseTimestampOrNow(t *testing.T) { t.Fatalf("ts = %v; want %v", ts, expTS) } - _, err = parseTimestampOrNow("123.45foo") + _, err = parseTimestampOrNow("123.45foo", testNower) if err == nil { t.Fatalf("err = nil; want %s", err) }