diff --git a/web/api/testhelpers/assertions.go b/web/api/testhelpers/assertions.go index 53010b08b5..8a0a0d4a97 100644 --- a/web/api/testhelpers/assertions.go +++ b/web/api/testhelpers/assertions.go @@ -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() diff --git a/web/api/v1/api_scenarios_test.go b/web/api/v1/api_scenarios_test.go index a707680c57..5bdccf08d5 100644 --- a/web/api/v1/api_scenarios_test.go +++ b/web/api/v1/api_scenarios_test.go @@ -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") + } + }) + } + } +} diff --git a/web/api/v1/openapi_schemas.go b/web/api/v1/openapi_schemas.go index 3a567983f4..de39b43e37 100644 --- a/web/api/v1/openapi_schemas.go +++ b/web/api/v1/openapi_schemas.go @@ -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")) diff --git a/web/api/v1/testdata/openapi_3.1_golden.yaml b/web/api/v1/testdata/openapi_3.1_golden.yaml index c69694b530..b1514f209d 100644 --- a/web/api/v1/testdata/openapi_3.1_golden.yaml +++ b/web/api/v1/testdata/openapi_3.1_golden.yaml @@ -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: diff --git a/web/api/v1/testdata/openapi_3.2_golden.yaml b/web/api/v1/testdata/openapi_3.2_golden.yaml index f122408013..fa79fffecc 100644 --- a/web/api/v1/testdata/openapi_3.2_golden.yaml +++ b/web/api/v1/testdata/openapi_3.2_golden.yaml @@ -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: