prometheus/web/api/v1/api_scenarios_test.go
Julien Pivotto e2d028a46e OpenAPI: Add support for stats
An oversight on the OpenAPI specification; which did not include stats.

Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
2026-01-30 14:21:03 +01:00

511 lines
16 KiB
Go

// Copyright 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 v1
import (
"strconv"
"testing"
"time"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/web/api/testhelpers"
)
// TODO: Generate automated tests from OpenAPI spec to validate API responses.
// TestAPIEmpty tests the API with no metrics and no rules.
func TestAPIEmpty(t *testing.T) {
// Create an API with empty defaults (no series, no rules).
api := newTestAPI(t, testhelpers.APIConfig{})
t.Run("GET /api/v1/labels returns success with empty array", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/labels").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data")
})
t.Run("GET /api/v1/query?query=up returns success (empty result ok)", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", "up").
ValidateOpenAPI().
RequireSuccess().
RequireEquals("$.data.resultType", "vector")
})
t.Run("GET /api/v1/query_range?query=up returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query_range",
"query", "up",
"start", "0",
"end", "100",
"step", "10").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "matrix")
})
t.Run("GET /api/v1/series returns success with empty result", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/series",
"match[]", "up",
"start", "0",
"end", "100").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data")
})
t.Run("GET /api/v1/label/__name__/values returns success with empty array", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/label/__name__/values").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data")
})
t.Run("GET /api/v1/targets returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/targets").
RequireSuccess().
RequireJSONPathExists("$.data.activeTargets")
})
t.Run("GET /api/v1/rules returns success with empty groups", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/rules").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.groups")
})
t.Run("GET /api/v1/alerts returns success with empty alerts", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/alerts").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.alerts")
})
t.Run("GET /api/v1/alertmanagers returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/alertmanagers").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.activeAlertmanagers")
})
t.Run("GET /api/v1/metadata returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/metadata").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data")
})
t.Run("GET /api/v1/status/config returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/status/config").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.yaml")
})
t.Run("GET /api/v1/status/flags returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/status/flags").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data")
})
t.Run("GET /api/v1/status/runtimeinfo returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/status/runtimeinfo").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data")
})
t.Run("GET /api/v1/status/buildinfo returns success", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/status/buildinfo").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data")
})
t.Run("POST /api/v1/query with form data returns success", func(t *testing.T) {
testhelpers.POST(t, api, "/api/v1/query", "query", "up").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector")
})
}
// TestAPIWithSeries tests the API with metrics/series data.
func TestAPIWithSeries(t *testing.T) {
// Create an API with sample series data.
api := newTestAPI(t, testhelpers.APIConfig{
Queryable: testhelpers.NewLazyLoader(func() storage.SampleAndChunkQueryable {
return testhelpers.NewQueryableWithSeries(testhelpers.FixtureMultipleSeries())
}),
})
t.Run("GET /api/v1/query returns vector with >= 1 sample", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", "up").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1)
})
t.Run("GET /api/v1/query_range returns matrix result type", func(t *testing.T) {
// Use relative timestamps to match our fixtures.
now := time.Now().Unix()
testhelpers.GET(t, api, "/api/v1/query_range",
"query", "up",
"start", strconv.FormatInt(now-120, 10),
"end", strconv.FormatInt(now, 10),
"step", "60").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "matrix")
// Note: Result may be empty if timestamps don't align perfectly with samples.
})
t.Run("GET /api/v1/labels returns non-empty array", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/labels").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data").
RequireLenAtLeast("$.data", 1)
})
t.Run("GET /api/v1/label/__name__/values contains expected metric names", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/label/__name__/values").
RequireSuccess().
ValidateOpenAPI().
RequireArrayContains("$.data", "up").
RequireArrayContains("$.data", "http_requests_total")
})
t.Run("GET /api/v1/label/job/values contains expected jobs", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/label/job/values").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data").
RequireArrayContains("$.data", "prometheus").
RequireArrayContains("$.data", "node").
RequireArrayContains("$.data", "api")
})
t.Run("GET /api/v1/series with match returns results", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/series",
"match[]", "up",
"start", "0",
"end", "120").
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data").
RequireLenAtLeast("$.data", 1)
})
t.Run("GET /api/v1/query with specific job returns filtered results", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", `up{job="prometheus"}`).
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1)
})
t.Run("GET /api/v1/query with aggregation returns result", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", "sum(up)").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector")
})
t.Run("POST /api/v1/query returns vector with data", func(t *testing.T) {
testhelpers.POST(t, api, "/api/v1/query", "query", "up").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1)
})
}
// TestAPIWithRules tests the API with rules configured.
func TestAPIWithRules(t *testing.T) {
// Create an API with rule groups.
api := newTestAPI(t, testhelpers.APIConfig{
RulesRetriever: testhelpers.NewLazyLoader(func() testhelpers.RulesRetriever {
return testhelpers.NewRulesRetrieverWithGroups(testhelpers.FixtureRuleGroups())
}),
})
t.Run("GET /api/v1/rules returns groups with rules", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/rules").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.groups").
RequireLenAtLeast("$.data.groups", 1).
RequireSome("$.data.groups", func(group any) bool {
if g, ok := group.(map[string]any); ok {
return g["name"] == "example"
}
return false
}).
RequireSome("$.data.groups", func(group any) bool {
if g, ok := group.(map[string]any); ok {
if g["name"] == "example" {
// Check that the group has rules.
if rules, ok := g["rules"].([]any); ok {
return len(rules) > 0
}
}
}
return false
})
})
t.Run("GET /api/v1/alerts returns alerts array", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/alerts").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.alerts").
RequireJSONArray("$.data.alerts")
})
t.Run("GET /api/v1/rules with rule_name filter", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/rules", "rule_name[]", "InstanceDown").
RequireSuccess().
ValidateOpenAPI().
RequireJSONPathExists("$.data.groups")
})
}
// TestAPITSDBNotReady tests the API when TSDB is not ready (e.g., during WAL replay).
// TSDB not ready errors are converted to errorUnavailable by setUnavailStatusOnTSDBNotReady,
// which returns HTTP 500 Internal Server Error (the default for errorUnavailable).
func TestAPITSDBNotReady(t *testing.T) {
// Create an API with a queryable that returns tsdb.ErrNotReady.
api := newTestAPI(t, testhelpers.APIConfig{
Queryable: testhelpers.NewLazyLoader(testhelpers.NewTSDBNotReadyQueryable),
})
t.Run("GET /api/v1/query returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", "up").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
t.Run("POST /api/v1/query returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.POST(t, api, "/api/v1/query", "query", "up").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
t.Run("GET /api/v1/query_range returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query_range",
"query", "up",
"start", "0",
"end", "100",
"step", "10").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
t.Run("GET /api/v1/series returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/series",
"match[]", "up",
"start", "0",
"end", "100").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
t.Run("GET /api/v1/labels returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/labels").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
t.Run("GET /api/v1/label/{name}/values returns 500 when TSDB not ready", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/label/__name__/values").
RequireStatusCode(500).
ValidateOpenAPI().
RequireError()
})
}
// TestAPIWithNativeHistograms tests the API with native histogram data.
func TestAPIWithNativeHistograms(t *testing.T) {
// Create an API with histogram series data.
api := newTestAPI(t, testhelpers.APIConfig{
Queryable: testhelpers.NewLazyLoader(func() storage.SampleAndChunkQueryable {
return testhelpers.NewQueryableWithSeries(testhelpers.FixtureHistogramSeries())
}),
})
t.Run("GET /api/v1/query returns vector with native histogram", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", "test_histogram").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1).
RequireSome("$.data.result", func(item any) bool {
sample, ok := item.(map[string]any)
if !ok {
return false
}
// Check that the sample has a histogram field (not a value field).
_, hasHistogram := sample["histogram"]
return hasHistogram
})
})
t.Run("POST /api/v1/query returns vector with native histogram", func(t *testing.T) {
testhelpers.POST(t, api, "/api/v1/query", "query", "test_histogram").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1).
RequireSome("$.data.result", func(item any) bool {
sample, ok := item.(map[string]any)
if !ok {
return false
}
// Check that the sample has a histogram field (not a value field).
_, hasHistogram := sample["histogram"]
return hasHistogram
})
})
t.Run("GET /api/v1/query_range returns matrix with native histogram", func(t *testing.T) {
// Use relative timestamps to match our fixtures.
now := time.Now().Unix()
testhelpers.GET(t, api, "/api/v1/query_range",
"query", "test_histogram",
"start", strconv.FormatInt(now-120, 10),
"end", strconv.FormatInt(now, 10),
"step", "60").
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "matrix")
})
t.Run("GET /api/v1/query with histogram selector", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/query", "query", `test_histogram{job="prometheus"}`).
RequireSuccess().
ValidateOpenAPI().
RequireEquals("$.data.resultType", "vector").
RequireLenAtLeast("$.data.result", 1)
})
t.Run("GET /api/v1/series returns histogram metric series", func(t *testing.T) {
testhelpers.GET(t, api, "/api/v1/series",
"match[]", "test_histogram",
"start", "0",
"end", strconv.FormatInt(time.Now().Unix(), 10)).
RequireSuccess().
ValidateOpenAPI().
RequireJSONArray("$.data").
RequireLenAtLeast("$.data", 1)
})
}
// TestAPIWithStats tests the API with the stats query parameter.
func TestAPIWithStats(t *testing.T) {
// Create an API with sample series data.
api := newTestAPI(t, testhelpers.APIConfig{
Queryable: testhelpers.NewLazyLoader(func() storage.SampleAndChunkQueryable {
return testhelpers.NewQueryableWithSeries(testhelpers.FixtureMultipleSeries())
}),
})
now := time.Now().Unix()
// Test combinations of methods, endpoints, and stats values.
methods := []string{"GET", "POST"}
statsValues := []struct {
value string
expectStats bool
}{
{"true", true},
{"all", true},
{"1", true},
{"", false},
}
for _, method := range methods {
for _, stats := range statsValues {
t.Run(method+" /api/v1/query with stats="+stats.value, func(t *testing.T) {
var params []string
if stats.value != "" {
params = []string{"query", "up", "stats", stats.value}
} else {
params = []string{"query", "up"}
}
var resp *testhelpers.Response
if method == "GET" {
resp = testhelpers.GET(t, api, "/api/v1/query", params...)
} else {
resp = testhelpers.POST(t, api, "/api/v1/query", params...)
}
resp.RequireSuccess().ValidateOpenAPI()
if stats.expectStats {
resp.RequireJSONPathExists("$.data.stats").
RequireJSONPathExists("$.data.stats.timings").
RequireJSONPathExists("$.data.stats.samples")
} else {
resp.RequireJSONPathNotExists("$.data.stats")
}
})
t.Run(method+" /api/v1/query_range with stats="+stats.value, func(t *testing.T) {
var params []string
if stats.value != "" {
params = []string{
"query", "up",
"start", strconv.FormatInt(now-120, 10),
"end", strconv.FormatInt(now, 10),
"step", "60",
"stats", stats.value,
}
} else {
params = []string{
"query", "up",
"start", strconv.FormatInt(now-120, 10),
"end", strconv.FormatInt(now, 10),
"step", "60",
}
}
var resp *testhelpers.Response
if method == "GET" {
resp = testhelpers.GET(t, api, "/api/v1/query_range", params...)
} else {
resp = testhelpers.POST(t, api, "/api/v1/query_range", params...)
}
resp.RequireSuccess().ValidateOpenAPI()
if stats.expectStats {
resp.RequireJSONPathExists("$.data.stats").
RequireJSONPathExists("$.data.stats.timings").
RequireJSONPathExists("$.data.stats.samples")
} else {
resp.RequireJSONPathNotExists("$.data.stats")
}
})
}
}
}