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>
This commit is contained in:
Julien Pivotto 2026-01-30 14:21:03 +01:00
parent 88f6ee4c8e
commit e2d028a46e
5 changed files with 278 additions and 0 deletions

View file

@ -55,6 +55,16 @@ func (r *Response) RequireJSONPathExists(path string) *Response {
return r
}
// RequireJSONPathNotExists asserts that a JSON path does not exist and returns the response for chaining.
func (r *Response) RequireJSONPathNotExists(path string) *Response {
r.t.Helper()
require.NotNil(r.t, r.JSON, "response body is not JSON")
value := getJSONPath(r.JSON, path)
require.Nil(r.t, value, "JSON path %q should not exist but was found", path)
return r
}
// RequireEquals asserts that a JSON path equals the expected value and returns the response for chaining.
func (r *Response) RequireEquals(path string, expected any) *Response {
r.t.Helper()

View file

@ -417,3 +417,94 @@ func TestAPIWithNativeHistograms(t *testing.T) {
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")
}
})
}
}
}

View file

@ -43,6 +43,7 @@ func (b *OpenAPIBuilder) buildComponents() *v3.Components {
schemas.Set("ParseQueryOutputBody", b.simpleResponseBodySchema())
schemas.Set("ParseQueryPostInputBody", b.parseQueryPostInputBodySchema())
schemas.Set("QueryData", b.queryDataSchema())
schemas.Set("QueryStats", b.queryStatsSchema())
schemas.Set("FloatSample", b.floatSampleSchema())
schemas.Set("HistogramSample", b.histogramSampleSchema())
schemas.Set("FloatSeries", b.floatSeriesSchema())
@ -450,6 +451,7 @@ func (*OpenAPIBuilder) queryDataSchema() *base.SchemaProxy {
},
})},
}))
vectorProps.Set("stats", schemaRef("#/components/schemas/QueryStats"))
// Matrix query result.
matrixProps := orderedmap.New[string, *base.SchemaProxy]()
@ -464,6 +466,7 @@ func (*OpenAPIBuilder) queryDataSchema() *base.SchemaProxy {
},
})},
}))
matrixProps.Set("stats", schemaRef("#/components/schemas/QueryStats"))
// Scalar query result.
scalarProps := orderedmap.New[string, *base.SchemaProxy]()
@ -480,6 +483,7 @@ func (*OpenAPIBuilder) queryDataSchema() *base.SchemaProxy {
MinItems: int64Ptr(2),
MaxItems: int64Ptr(2),
}))
scalarProps.Set("stats", schemaRef("#/components/schemas/QueryStats"))
// String query result.
stringResultProps := orderedmap.New[string, *base.SchemaProxy]()
@ -491,6 +495,7 @@ func (*OpenAPIBuilder) queryDataSchema() *base.SchemaProxy {
MinItems: int64Ptr(2),
MaxItems: int64Ptr(2),
}))
stringResultProps.Set("stats", schemaRef("#/components/schemas/QueryStats"))
return base.CreateSchemaProxy(&base.Schema{
Description: "Query result data. The structure of 'result' depends on 'resultType'.",
@ -536,6 +541,74 @@ func (*OpenAPIBuilder) queryDataSchema() *base.SchemaProxy {
})
}
func (*OpenAPIBuilder) queryStatsSchema() *base.SchemaProxy {
// Timings object.
timingsProps := orderedmap.New[string, *base.SchemaProxy]()
timingsProps.Set("evalTotalTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Total evaluation time in seconds.",
}))
timingsProps.Set("resultSortTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Time spent sorting results in seconds.",
}))
timingsProps.Set("queryPreparationTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Query preparation time in seconds.",
}))
timingsProps.Set("innerEvalTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Inner evaluation time in seconds.",
}))
timingsProps.Set("execQueueTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Execution queue wait time in seconds.",
}))
timingsProps.Set("execTotalTime", base.CreateSchemaProxy(&base.Schema{
Type: []string{"number"},
Description: "Total execution time in seconds.",
}))
// Samples object.
samplesProps := orderedmap.New[string, *base.SchemaProxy]()
samplesProps.Set("totalQueryableSamples", base.CreateSchemaProxy(&base.Schema{
Type: []string{"integer"},
Description: "Total number of samples that were queryable.",
}))
samplesProps.Set("peakSamples", base.CreateSchemaProxy(&base.Schema{
Type: []string{"integer"},
Description: "Peak number of samples in memory.",
}))
samplesProps.Set("totalQueryableSamplesPerStep", base.CreateSchemaProxy(&base.Schema{
Type: []string{"array"},
Description: "Total queryable samples per step (only included with stats=all).",
Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: base.CreateSchemaProxy(&base.Schema{
Type: []string{"array"},
Description: "Timestamp and sample count as [timestamp, count].",
Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: base.CreateSchemaProxy(&base.Schema{Type: []string{"number"}})},
MinItems: int64Ptr(2),
MaxItems: int64Ptr(2),
})},
}))
// Main stats object.
statsProps := orderedmap.New[string, *base.SchemaProxy]()
statsProps.Set("timings", base.CreateSchemaProxy(&base.Schema{
Type: []string{"object"},
Properties: timingsProps,
}))
statsProps.Set("samples", base.CreateSchemaProxy(&base.Schema{
Type: []string{"object"},
Properties: samplesProps,
}))
return base.CreateSchemaProxy(&base.Schema{
Type: []string{"object"},
Description: "Query execution statistics (included when the stats query parameter is provided).",
Properties: statsProps,
})
}
func (*OpenAPIBuilder) queryPostInputBodySchema() *base.SchemaProxy {
props := orderedmap.New[string, *base.SchemaProxy]()
props.Set("query", stringSchemaWithDescriptionAndExample("Form field: The PromQL query to execute.", "up"))

View file

@ -2843,6 +2843,8 @@ components:
- $ref: '#/components/schemas/FloatSample'
- $ref: '#/components/schemas/HistogramSample'
description: Array of samples (either float or histogram).
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2860,6 +2862,8 @@ components:
- $ref: '#/components/schemas/FloatSeries'
- $ref: '#/components/schemas/HistogramSeries'
description: Array of time series (either float or histogram).
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2879,6 +2883,8 @@ components:
maxItems: 2
minItems: 2
description: Scalar value as [timestamp, stringValue].
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2896,6 +2902,8 @@ components:
maxItems: 2
minItems: 2
description: String value as [timestamp, stringValue].
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2910,6 +2918,50 @@ components:
- 1627845600
- "1"
resultType: vector
QueryStats:
type: object
properties:
timings:
type: object
properties:
evalTotalTime:
type: number
description: Total evaluation time in seconds.
resultSortTime:
type: number
description: Time spent sorting results in seconds.
queryPreparationTime:
type: number
description: Query preparation time in seconds.
innerEvalTime:
type: number
description: Inner evaluation time in seconds.
execQueueTime:
type: number
description: Execution queue wait time in seconds.
execTotalTime:
type: number
description: Total execution time in seconds.
samples:
type: object
properties:
totalQueryableSamples:
type: integer
description: Total number of samples that were queryable.
peakSamples:
type: integer
description: Peak number of samples in memory.
totalQueryableSamplesPerStep:
type: array
items:
type: array
items:
type: number
maxItems: 2
minItems: 2
description: Timestamp and sample count as [timestamp, count].
description: Total queryable samples per step (only included with stats=all).
description: Query execution statistics (included when the stats query parameter is provided).
FloatSample:
type: object
properties:

View file

@ -2881,6 +2881,8 @@ components:
- $ref: '#/components/schemas/FloatSample'
- $ref: '#/components/schemas/HistogramSample'
description: Array of samples (either float or histogram).
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2898,6 +2900,8 @@ components:
- $ref: '#/components/schemas/FloatSeries'
- $ref: '#/components/schemas/HistogramSeries'
description: Array of time series (either float or histogram).
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2917,6 +2921,8 @@ components:
maxItems: 2
minItems: 2
description: Scalar value as [timestamp, stringValue].
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2934,6 +2940,8 @@ components:
maxItems: 2
minItems: 2
description: String value as [timestamp, stringValue].
stats:
$ref: '#/components/schemas/QueryStats'
required:
- resultType
- result
@ -2948,6 +2956,50 @@ components:
- 1627845600
- "1"
resultType: vector
QueryStats:
type: object
properties:
timings:
type: object
properties:
evalTotalTime:
type: number
description: Total evaluation time in seconds.
resultSortTime:
type: number
description: Time spent sorting results in seconds.
queryPreparationTime:
type: number
description: Query preparation time in seconds.
innerEvalTime:
type: number
description: Inner evaluation time in seconds.
execQueueTime:
type: number
description: Execution queue wait time in seconds.
execTotalTime:
type: number
description: Total execution time in seconds.
samples:
type: object
properties:
totalQueryableSamples:
type: integer
description: Total number of samples that were queryable.
peakSamples:
type: integer
description: Peak number of samples in memory.
totalQueryableSamplesPerStep:
type: array
items:
type: array
items:
type: number
maxItems: 2
minItems: 2
description: Timestamp and sample count as [timestamp, count].
description: Total queryable samples per step (only included with stats=all).
description: Query execution statistics (included when the stats query parameter is provided).
FloatSample:
type: object
properties: