mirror of
https://github.com/prometheus/prometheus.git
synced 2026-05-28 04:02:21 -04:00
Some checks are pending
buf.build / lint and publish (push) Waiting to run
CI / Go tests (push) Waiting to run
CI / More Go tests (push) Waiting to run
CI / Go tests for 32-bit x86 (push) Waiting to run
CI / Go tests for Prometheus upgrades and downgrades (push) Waiting to run
CI / Go tests with previous Go version (push) Waiting to run
CI / UI tests (push) Waiting to run
CI / Go tests on Windows (push) Waiting to run
CI / Mixins tests (push) Waiting to run
CI / Compliance testing (push) Waiting to run
CI / Build Prometheus for common architectures (push) Waiting to run
CI / Build Prometheus for all architectures (push) Waiting to run
CI / Report status of build Prometheus for all architectures (push) Blocked by required conditions
CI / Check generated parser (push) Waiting to run
CI / golangci-lint (push) Waiting to run
CI / fuzzing (push) Waiting to run
CI / codeql (push) Waiting to run
CI / Publish main branch artifacts (push) Blocked by required conditions
CI / Publish release artefacts (push) Blocked by required conditions
CI / Publish UI on npm Registry (push) Blocked by required conditions
govulncheck / Run govulncheck (push) Waiting to run
Scorecards supply-chain security / Scorecards analysis (push) Waiting to run
* web/api: reject 0 for limit and batch_size in search endpoints Treat 0 as invalid for limit and batch_size query parameters; clients must supply a positive integer or omit the parameter to use the server default of 100. Update OpenAPI descriptions accordingly. Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> * Update web/api/v1/openapi.go Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com> Signed-off-by: Julien <291750+roidelapluie@users.noreply.github.com> --------- Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> Signed-off-by: Julien <291750+roidelapluie@users.noreply.github.com> Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
1322 lines
44 KiB
Go
1322 lines
44 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 (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/prometheus/common/model"
|
|
"github.com/prometheus/common/route"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/prometheus/prometheus/config"
|
|
"github.com/prometheus/prometheus/model/labels"
|
|
"github.com/prometheus/prometheus/promql/parser"
|
|
"github.com/prometheus/prometheus/promql/promqltest"
|
|
"github.com/prometheus/prometheus/scrape"
|
|
"github.com/prometheus/prometheus/storage"
|
|
"github.com/prometheus/prometheus/tsdb"
|
|
"github.com/prometheus/prometheus/util/annotations"
|
|
)
|
|
|
|
// newSearchTestAPI creates a minimal API suitable for search endpoint testing.
|
|
func newSearchTestAPI(t *testing.T) *API {
|
|
t.Helper()
|
|
|
|
s := promqltest.LoadedStorage(t, `
|
|
load 1m
|
|
go_gc_duration_seconds{instance="localhost:9090", job="prometheus"} 0+1x100
|
|
go_gc_heap_goal_bytes{instance="localhost:9090", job="prometheus"} 0+1x100
|
|
go_goroutines{instance="localhost:9090", job="prometheus"} 0+1x100
|
|
up{instance="localhost:9090", job="prometheus"} 1+0x100
|
|
up{instance="localhost:9091", job="node"} 1+0x100
|
|
process_cpu_seconds_total{instance="localhost:9090", job="prometheus"} 0+1x100
|
|
`)
|
|
|
|
tr := newTestTargetRetriever([]*testTargetParams{
|
|
{
|
|
Identifier: "test",
|
|
Labels: labels.FromMap(map[string]string{
|
|
model.SchemeLabel: "http",
|
|
model.AddressLabel: "localhost:9090",
|
|
model.MetricsPathLabel: "/metrics",
|
|
model.JobLabel: "prometheus",
|
|
}),
|
|
Params: url.Values{},
|
|
Active: true,
|
|
},
|
|
})
|
|
require.NoError(t, tr.SetMetadataStoreForTargets("test", &testMetaStore{
|
|
Metadata: []scrape.MetricMetadata{
|
|
{MetricFamily: "go_gc_duration_seconds", Type: model.MetricTypeGauge, Help: "GC duration.", Unit: "seconds"},
|
|
{MetricFamily: "go_goroutines", Type: model.MetricTypeGauge, Help: "Number of goroutines."},
|
|
{MetricFamily: "up", Type: model.MetricTypeGauge, Help: "Target is up."},
|
|
},
|
|
}))
|
|
|
|
// The test data is loaded from t=0 to t=6000s (100 minutes at 1m intervals).
|
|
// Use a fixed now at t=7200s so the default 1-hour lookback [3600s, 7200s] covers the data.
|
|
fixedNow := time.Unix(7200, 0)
|
|
return &API{
|
|
Queryable: s,
|
|
targetRetriever: tr.toFactory(),
|
|
now: func() time.Time { return fixedNow },
|
|
config: func() config.Config { return samplePrometheusCfg },
|
|
ready: func(f http.HandlerFunc) http.HandlerFunc { return f },
|
|
parser: parser.NewParser(parser.Options{}),
|
|
enableSearch: true,
|
|
metaCache: &searchMetadataCache{},
|
|
}
|
|
}
|
|
|
|
// minimalSearchAPI returns an API with only the fields a search-endpoint
|
|
// test needs when it doesn't want the full target-retriever/storage
|
|
// scaffolding from newSearchTestAPI. metaCache is initialized so the
|
|
// metadata-cache path matches production. Callers must set Queryable
|
|
// before use.
|
|
func minimalSearchAPI() *API {
|
|
return &API{
|
|
now: func() time.Time { return time.Unix(7200, 0) },
|
|
ready: func(f http.HandlerFunc) http.HandlerFunc { return f },
|
|
parser: parser.NewParser(parser.Options{}),
|
|
enableSearch: true,
|
|
metaCache: &searchMetadataCache{},
|
|
}
|
|
}
|
|
|
|
// parseNDJSON parses NDJSON response body into individual JSON objects.
|
|
func parseNDJSON(t *testing.T, body string) []json.RawMessage {
|
|
t.Helper()
|
|
var lines []json.RawMessage
|
|
scanner := bufio.NewScanner(strings.NewReader(body))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
lines = append(lines, json.RawMessage(line))
|
|
}
|
|
require.NoError(t, scanner.Err())
|
|
return lines
|
|
}
|
|
|
|
// doSearchRequest performs a GET request to the search endpoint.
|
|
func doSearchRequest(t *testing.T, api *API, path string, params url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return doSearchRequestCtx(t.Context(), t, api, path, params)
|
|
}
|
|
|
|
// doSearchRequestCtx performs a GET request to the search endpoint with a
|
|
// caller-supplied context. A nil ctx uses the default request context.
|
|
func doSearchRequestCtx(ctx context.Context, t *testing.T, api *API, path string, params url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
r := route.New()
|
|
api.Register(r)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, path+"?"+params.Encode(), http.NoBody)
|
|
if ctx != nil {
|
|
req = req.WithContext(ctx)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
type errorSearchQuerier struct {
|
|
errorTestQuerier
|
|
err error
|
|
}
|
|
|
|
func (q errorSearchQuerier) SearchLabelNames(context.Context, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
return storage.ErrSearchResultSet(q.err)
|
|
}
|
|
|
|
func (q errorSearchQuerier) SearchLabelValues(context.Context, string, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
return storage.ErrSearchResultSet(q.err)
|
|
}
|
|
|
|
func TestSearchEndpointsMapTSDBNotReadyToUnavailable(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
queryable errorTestQueryable
|
|
}{
|
|
{
|
|
name: "querier error",
|
|
queryable: errorTestQueryable{err: tsdb.ErrNotReady},
|
|
},
|
|
{
|
|
name: "search result error",
|
|
queryable: errorTestQueryable{
|
|
q: errorSearchQuerier{err: tsdb.ErrNotReady},
|
|
},
|
|
},
|
|
}
|
|
|
|
endpoints := []struct {
|
|
name string
|
|
path string
|
|
params url.Values
|
|
}{
|
|
{name: "metric names", path: "/search/metric_names"},
|
|
{name: "label names", path: "/search/label_names"},
|
|
{name: "label values", path: "/search/label_values", params: url.Values{"label": []string{"job"}}},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
api := minimalSearchAPI()
|
|
api.Queryable = tc.queryable
|
|
|
|
for _, endpoint := range endpoints {
|
|
t.Run(endpoint.name, func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, endpoint.path, endpoint.params)
|
|
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
|
|
|
var response Response
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
|
require.Equal(t, statusError, response.Status)
|
|
require.Equal(t, errorUnavailable.str, response.ErrorType)
|
|
require.Contains(t, response.Error, tsdb.ErrNotReady.Error())
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchMetricNames(t *testing.T) {
|
|
api := newSearchTestAPI(t)
|
|
|
|
t.Run("basic search", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_gc"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, "application/x-ndjson; charset=utf-8", rec.Header().Get("Content-Type"))
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
require.GreaterOrEqual(t, len(lines), 2) // At least one batch + trailer.
|
|
|
|
// Check trailer.
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.Equal(t, "success", trailer.Status)
|
|
|
|
// Check first batch contains results.
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.NotEmpty(t, batch.Results)
|
|
|
|
// All results should contain "go_gc".
|
|
for _, r := range batch.Results {
|
|
require.Contains(t, r.Name, "go_gc")
|
|
}
|
|
})
|
|
|
|
t.Run("empty search returns all", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
// Should have all 5 distinct metric names.
|
|
require.Len(t, batch.Results, 5)
|
|
})
|
|
|
|
t.Run("with limit", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"limit": []string{"2"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 2)
|
|
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.True(t, trailer.HasMore)
|
|
})
|
|
|
|
t.Run("with metadata", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_gc_duration"},
|
|
"include_metadata": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
require.Equal(t, "go_gc_duration_seconds", batch.Results[0].Name)
|
|
require.Equal(t, "gauge", batch.Results[0].Type)
|
|
require.Equal(t, "GC duration.", batch.Results[0].Help)
|
|
require.Equal(t, "seconds", batch.Results[0].Unit)
|
|
})
|
|
|
|
t.Run("with include_score", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_gc"},
|
|
"include_score": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.NotEmpty(t, batch.Results)
|
|
for _, r := range batch.Results {
|
|
require.NotNil(t, r.Score, "score should be set when include_score=true")
|
|
}
|
|
})
|
|
|
|
t.Run("without include_score score is absent", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_gc"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.NotEmpty(t, batch.Results)
|
|
for _, r := range batch.Results {
|
|
require.Nil(t, r.Score, "score should be absent when include_score is not set")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown params are ignored", func(t *testing.T) {
|
|
// include_cardinality is no longer supported; the endpoint should succeed and ignore it.
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"up"},
|
|
"include_cardinality": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
require.Equal(t, "up", batch.Results[0].Name)
|
|
})
|
|
|
|
t.Run("sort by alpha ascending", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_by": []string{"alpha"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
for i := 1; i < len(batch.Results); i++ {
|
|
require.LessOrEqual(t, batch.Results[i-1].Name, batch.Results[i].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("sort by alpha descending", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_by": []string{"alpha"},
|
|
"sort_dir": []string{"dsc"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
for i := 1; i < len(batch.Results); i++ {
|
|
require.GreaterOrEqual(t, batch.Results[i-1].Name, batch.Results[i].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("sort_by=score without search[] is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_by": []string{"score"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("case insensitive search", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"GO_GC"},
|
|
"case_sensitive": []string{"false"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.NotEmpty(t, batch.Results)
|
|
for _, r := range batch.Results {
|
|
require.Contains(t, strings.ToLower(r.Name), "go_gc")
|
|
}
|
|
})
|
|
|
|
t.Run("fuzzy search", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_goroutins"},
|
|
"fuzz_threshold": []string{"80"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
// Should find go_goroutines via fuzzy match.
|
|
found := false
|
|
for _, r := range batch.Results {
|
|
if r.Name == "go_goroutines" {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "expected to find go_goroutines via fuzzy match")
|
|
})
|
|
|
|
t.Run("invalid sort_by", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_by": []string{"invalid"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("invalid sort_dir", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_dir": []string{"invalid"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("invalid fuzz_threshold", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"fuzz_threshold": []string{"200"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("invalid bool params", func(t *testing.T) {
|
|
tests := []string{
|
|
"case_sensitive",
|
|
"include_score",
|
|
"include_metadata",
|
|
}
|
|
|
|
for _, param := range tests {
|
|
t.Run(param, func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
param: []string{"maybe"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "bad_data")
|
|
require.Contains(t, rec.Body.String(), "invalid "+param)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("sort_dir without sort_by", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"sort_dir": []string{"asc"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("unsupported fuzz_alg", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"fuzz_alg": []string{"levenshtein"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("valid fuzz_alg", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"go_gc"},
|
|
"fuzz_alg": []string{"jarowinkler"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
})
|
|
|
|
for _, v := range []string{"0", "-1"} {
|
|
t.Run("batch_size "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"batch_size": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
t.Run("limit "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"limit": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
}
|
|
|
|
t.Run("sort_by cardinality is invalid", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"up"},
|
|
"sort_by": []string{"cardinality"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("sort_dir with sort_by=score is invalid", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": []string{"up"},
|
|
"sort_by": []string{"score"},
|
|
"sort_dir": []string{"asc"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("batch_size splitting", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"batch_size": []string{"2"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
// With 5 metrics and batch_size=2, should have 3 batch lines (2+2+1) + 1 trailer.
|
|
require.Len(t, lines, 4)
|
|
|
|
// First two batches should have 2 results each.
|
|
for i := range 2 {
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[i], &batch))
|
|
require.Len(t, batch.Results, 2)
|
|
}
|
|
// Third batch should have 1 result (5 total).
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[2], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
})
|
|
|
|
t.Run("end before start is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"start": []string{"7200"},
|
|
"end": []string{"3600"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "end timestamp must not be before start timestamp")
|
|
})
|
|
|
|
t.Run("limit cap rejects excessive limits", func(t *testing.T) {
|
|
capped := newSearchTestAPI(t)
|
|
capped.maxSearchLimit = 50
|
|
|
|
rec := doSearchRequest(t, capped, "/search/metric_names", url.Values{
|
|
"limit": []string{"5000"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "exceeds the configured maximum")
|
|
})
|
|
|
|
t.Run("limit cap below default still serves request", func(t *testing.T) {
|
|
// Operators with a small cap should still be able to serve requests
|
|
// that omit "limit"; the implicit default must shrink to the cap,
|
|
// and the trailer must still report has_more so the client knows
|
|
// the cap truncated the result set.
|
|
capped := newSearchTestAPI(t)
|
|
capped.maxSearchLimit = 3
|
|
|
|
rec := doSearchRequest(t, capped, "/search/metric_names", url.Values{})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
require.NotEmpty(t, lines)
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.LessOrEqual(t, len(batch.Results), 3,
|
|
"default limit must be clamped to maxSearchLimit when smaller")
|
|
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.Equal(t, "success", trailer.Status)
|
|
require.True(t, trailer.HasMore,
|
|
"has_more must be true when the cap truncated a fixture with more values than the cap")
|
|
})
|
|
|
|
t.Run("limit cap allows in-range explicit limit", func(t *testing.T) {
|
|
capped := newSearchTestAPI(t)
|
|
capped.maxSearchLimit = 50
|
|
|
|
rec := doSearchRequest(t, capped, "/search/metric_names", url.Values{
|
|
"limit": []string{"2"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
})
|
|
|
|
t.Run("limit cap zero disables the cap", func(t *testing.T) {
|
|
// maxSearchLimit defaults to 0 in the test fixture; oversize limits
|
|
// must therefore be accepted.
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"limit": []string{"100000"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
})
|
|
|
|
t.Run("multiple match sets are merged and deduplicated", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"match[]": []string{"up", `{job="prometheus"}`},
|
|
"sort_by": []string{"alpha"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 5)
|
|
|
|
seen := map[string]struct{}{}
|
|
for i, r := range batch.Results {
|
|
_, ok := seen[r.Name]
|
|
require.False(t, ok, "duplicate result %q", r.Name)
|
|
seen[r.Name] = struct{}{}
|
|
if i > 0 {
|
|
require.LessOrEqual(t, batch.Results[i-1].Name, r.Name)
|
|
}
|
|
}
|
|
require.Contains(t, seen, "up")
|
|
})
|
|
}
|
|
|
|
func TestSearchLabelNames(t *testing.T) {
|
|
api := newSearchTestAPI(t)
|
|
|
|
t.Run("basic search", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"search[]": []string{"inst"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.NotEmpty(t, batch.Results)
|
|
found := false
|
|
for _, r := range batch.Results {
|
|
if r.Name == "instance" {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found)
|
|
})
|
|
|
|
t.Run("empty search returns all", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
// Should have __name__, instance, job at minimum.
|
|
require.GreaterOrEqual(t, len(batch.Results), 3)
|
|
})
|
|
|
|
t.Run("search by exact name", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"search[]": []string{"job"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
require.Equal(t, "job", batch.Results[0].Name)
|
|
})
|
|
|
|
t.Run("sort by alpha", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"sort_by": []string{"alpha"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
for i := 1; i < len(batch.Results); i++ {
|
|
require.LessOrEqual(t, batch.Results[i-1].Name, batch.Results[i].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid sort_by", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"sort_by": []string{"invalid"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
for _, v := range []string{"0", "-1"} {
|
|
t.Run("limit "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"limit": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
t.Run("batch_size "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_names", url.Values{
|
|
"batch_size": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchLabelValues(t *testing.T) {
|
|
api := newSearchTestAPI(t)
|
|
|
|
t.Run("basic search", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"search[]": []string{"prom"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
require.Equal(t, "prometheus", batch.Results[0].Value)
|
|
})
|
|
|
|
t.Run("missing label parameter", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("all values for label", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 2) // "prometheus" and "node".
|
|
})
|
|
|
|
t.Run("search exact value", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"search[]": []string{"prometheus"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
require.Equal(t, "prometheus", batch.Results[0].Value)
|
|
})
|
|
|
|
t.Run("sort by alpha descending", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"sort_by": []string{"alpha"},
|
|
"sort_dir": []string{"dsc"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 2)
|
|
require.Equal(t, "prometheus", batch.Results[0].Value)
|
|
require.Equal(t, "node", batch.Results[1].Value)
|
|
})
|
|
|
|
t.Run("with limit and has_more", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"instance"},
|
|
"limit": []string{"1"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Len(t, batch.Results, 1)
|
|
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.True(t, trailer.HasMore)
|
|
})
|
|
|
|
t.Run("invalid sort_by", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"sort_by": []string{"cardinality"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("sort_by frequency is invalid", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"sort_by": []string{"frequency"},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
for _, v := range []string{"0", "-1"} {
|
|
t.Run("limit "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"limit": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
t.Run("batch_size "+v+" is rejected", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"batch_size": []string{v},
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "must be a positive integer")
|
|
})
|
|
}
|
|
|
|
t.Run("multiple search terms OR logic", func(t *testing.T) {
|
|
rec := doSearchRequest(t, api, "/search/label_values", url.Values{
|
|
"label": []string{"job"},
|
|
"search[]": []string{"prometheus", "node"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchLabelValueResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
// Both "prometheus" and "node" should be returned.
|
|
require.Len(t, batch.Results, 2)
|
|
})
|
|
}
|
|
|
|
func TestMatchName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
search string
|
|
fuzzThreshold int
|
|
caseSensitive bool
|
|
wantMatched bool
|
|
wantScore float64
|
|
}{
|
|
{
|
|
// Empty searches must produce a nil filter; the subtest
|
|
// returns early after asserting that, so the matched/score
|
|
// fields below are unused for this row.
|
|
name: "go_goroutines", search: "", fuzzThreshold: 100, caseSensitive: true,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "go_gor", fuzzThreshold: 100, caseSensitive: true,
|
|
wantMatched: true, wantScore: 1.0,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "GO_GOR", fuzzThreshold: 100, caseSensitive: true,
|
|
wantMatched: false,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "GO_GOR", fuzzThreshold: 100, caseSensitive: false,
|
|
wantMatched: true, wantScore: 1.0,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "xyz_not_found", fuzzThreshold: 100, caseSensitive: true,
|
|
wantMatched: false,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "go_goroutins", fuzzThreshold: 80, caseSensitive: true,
|
|
wantMatched: true,
|
|
},
|
|
{
|
|
name: "go_goroutines", search: "go_goroutins", fuzzThreshold: 100, caseSensitive: true,
|
|
wantMatched: false, // Fuzzy disabled with threshold=100.
|
|
},
|
|
{
|
|
// Substring but not prefix gets a position-based score < 1.0.
|
|
// idx=3, maxIdx=13-9=4 -> 1.0 - 0.9*3/4 = 0.325.
|
|
name: "go_goroutines", search: "goroutine", fuzzThreshold: 100, caseSensitive: true,
|
|
wantMatched: true, wantScore: 0.325,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name+"_"+tt.search, func(t *testing.T) {
|
|
searches := []string{tt.search}
|
|
if tt.search == "" {
|
|
searches = nil
|
|
}
|
|
filter := buildSearchFilter(searches, tt.fuzzThreshold, "jarowinkler", tt.caseSensitive)
|
|
|
|
if tt.search == "" {
|
|
require.Nil(t, filter, "empty searches must yield a nil filter")
|
|
return
|
|
}
|
|
matched, score := filter.Accept(tt.name)
|
|
|
|
require.Equal(t, tt.wantMatched, matched)
|
|
if tt.wantMatched && tt.wantScore > 0 {
|
|
require.InDelta(t, tt.wantScore, score, 0.01)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// countingTargetRetriever wraps a TargetRetriever and counts TargetsActive
|
|
// invocations, used to assert that buildMetricMetadataMap is called once per
|
|
// search request rather than once per result.
|
|
type countingTargetRetriever struct {
|
|
inner TargetRetriever
|
|
targetsActiveCnt int
|
|
}
|
|
|
|
func (c *countingTargetRetriever) ScrapePoolConfig(pool string) (*config.ScrapeConfig, error) {
|
|
return c.inner.ScrapePoolConfig(pool)
|
|
}
|
|
|
|
func (c *countingTargetRetriever) TargetsActive() map[string][]*scrape.Target {
|
|
c.targetsActiveCnt++
|
|
return c.inner.TargetsActive()
|
|
}
|
|
|
|
func (c *countingTargetRetriever) TargetsDropped() map[string][]*scrape.Target {
|
|
return c.inner.TargetsDropped()
|
|
}
|
|
|
|
func (c *countingTargetRetriever) TargetsDroppedCounts() map[string]int {
|
|
return c.inner.TargetsDroppedCounts()
|
|
}
|
|
|
|
func TestSearchMetricNamesMetadataMapBuiltOnce(t *testing.T) {
|
|
api := newSearchTestAPI(t)
|
|
counting := &countingTargetRetriever{inner: api.targetRetriever(t.Context())}
|
|
api.targetRetriever = func(context.Context) TargetRetriever { return counting }
|
|
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"include_metadata": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
var batch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.GreaterOrEqual(t, len(batch.Results), 5)
|
|
|
|
require.Equal(t, 1, counting.targetsActiveCnt,
|
|
"buildMetricMetadataMap must call TargetsActive exactly once per request")
|
|
}
|
|
|
|
func TestSearchMetricNamesMetadataMapCachedAcrossRequests(t *testing.T) {
|
|
// The TTL cache must serve the second request without re-locking the
|
|
// scrape manager. A regression that drops the cache check would make
|
|
// every keystroke in an autocomplete UI walk every active target.
|
|
api := newSearchTestAPI(t)
|
|
counting := &countingTargetRetriever{inner: api.targetRetriever(t.Context())}
|
|
api.targetRetriever = func(context.Context) TargetRetriever { return counting }
|
|
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"include_metadata": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, 1, counting.targetsActiveCnt,
|
|
"first request must build the metadata map exactly once")
|
|
|
|
rec = doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"include_metadata": []string{"true"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, 1, counting.targetsActiveCnt,
|
|
"second request within TTL must hit the cache; targetsActiveCnt must not grow")
|
|
}
|
|
|
|
func TestSearchMetricNamesMetadataMapNotBuiltWhenEmpty(t *testing.T) {
|
|
// With no matching results, the lazy metadata builder must not run at
|
|
// all so that the request avoids the scrape-manager lock entirely.
|
|
api := newSearchTestAPI(t)
|
|
counting := &countingTargetRetriever{inner: api.targetRetriever(t.Context())}
|
|
api.targetRetriever = func(context.Context) TargetRetriever { return counting }
|
|
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"include_metadata": []string{"true"},
|
|
"search[]": []string{"this_metric_definitely_does_not_exist_xyz"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, 0, counting.targetsActiveCnt,
|
|
"buildMetricMetadataMap must not be called when no results are emitted")
|
|
}
|
|
|
|
// sequentialSearchQuerier returns a different SearchResultSet for each
|
|
// successive call to SearchLabelNames or SearchLabelValues. It is used to test
|
|
// the multi-matcher-set merge behaviour where one set fails and the others
|
|
// must still surface their results.
|
|
type sequentialSearchQuerier struct {
|
|
errorTestQuerier
|
|
calls []sequentialResp
|
|
labelNameCount int
|
|
labelValueCount int
|
|
}
|
|
|
|
type sequentialResp struct {
|
|
results []storage.SearchResult
|
|
err error
|
|
}
|
|
|
|
func (q *sequentialSearchQuerier) nextResp(idx int) sequentialResp {
|
|
if idx >= len(q.calls) {
|
|
return sequentialResp{}
|
|
}
|
|
return q.calls[idx]
|
|
}
|
|
|
|
func (q *sequentialSearchQuerier) SearchLabelNames(context.Context, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
r := q.nextResp(q.labelNameCount)
|
|
q.labelNameCount++
|
|
if r.err != nil {
|
|
return storage.ErrSearchResultSet(r.err)
|
|
}
|
|
return storage.NewSearchResultSetFromSlice(r.results, nil)
|
|
}
|
|
|
|
func (q *sequentialSearchQuerier) SearchLabelValues(context.Context, string, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
r := q.nextResp(q.labelValueCount)
|
|
q.labelValueCount++
|
|
if r.err != nil {
|
|
return storage.ErrSearchResultSet(r.err)
|
|
}
|
|
return storage.NewSearchResultSetFromSlice(r.results, nil)
|
|
}
|
|
|
|
func TestSearchEndpointsPartialMatcherSetError(t *testing.T) {
|
|
// Two match[] sets: the first yields three values, the second errors.
|
|
// The merge should emit the surviving set's values and the streamer
|
|
// then writes a mid-stream error line.
|
|
q := &sequentialSearchQuerier{
|
|
calls: []sequentialResp{
|
|
{results: []storage.SearchResult{
|
|
{Value: "alpha", Score: 1.0},
|
|
{Value: "beta", Score: 1.0},
|
|
{Value: "gamma", Score: 1.0},
|
|
}},
|
|
{err: errors.New("simulated set failure")},
|
|
},
|
|
}
|
|
queryable := errorTestQueryable{q: q}
|
|
|
|
api := minimalSearchAPI()
|
|
api.Queryable = queryable
|
|
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"match[]": []string{"up", `{job="prometheus"}`},
|
|
"batch_size": []string{"2"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
require.NotEmpty(t, lines)
|
|
|
|
// Concatenate batch values across all batch lines.
|
|
var values []string
|
|
var lastIsError bool
|
|
for i, line := range lines {
|
|
var trailer searchTrailer
|
|
if err := json.Unmarshal(line, &trailer); err == nil && trailer.Status != "" {
|
|
if i == len(lines)-1 && trailer.Status == "error" {
|
|
lastIsError = true
|
|
continue
|
|
}
|
|
}
|
|
var batch searchBatch[searchMetricNameResult]
|
|
if err := json.Unmarshal(line, &batch); err == nil && batch.Results != nil {
|
|
for _, r := range batch.Results {
|
|
values = append(values, r.Name)
|
|
}
|
|
}
|
|
}
|
|
require.Equal(t, []string{"alpha", "beta", "gamma"}, values, "values from the surviving match[] set must reach the client")
|
|
require.True(t, lastIsError, "stream must terminate with an NDJSON error line, not a success trailer")
|
|
}
|
|
|
|
func TestSearchEndpointsCtxCancelled(t *testing.T) {
|
|
// Use a controlled fake searcher so the test does not depend on the
|
|
// shape of any storage fixture. With three explicit results and
|
|
// batch_size=1, firstBatch is non-empty and the streaming loop must
|
|
// observe the pre-cancelled context on its first iteration and return
|
|
// before writing a success trailer.
|
|
q := &sequentialSearchQuerier{
|
|
calls: []sequentialResp{
|
|
{results: []storage.SearchResult{
|
|
{Value: "alpha", Score: 1.0},
|
|
{Value: "beta", Score: 1.0},
|
|
{Value: "gamma", Score: 1.0},
|
|
}},
|
|
},
|
|
}
|
|
queryable := errorTestQueryable{q: q}
|
|
|
|
api := minimalSearchAPI()
|
|
api.Queryable = queryable
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
rec := doSearchRequestCtx(ctx, t, api, "/search/metric_names", url.Values{
|
|
"batch_size": []string{"1"},
|
|
})
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
|
|
// The streaming loop only checks ctx.Done() AFTER writing the first
|
|
// batch, so a successful run always emits at least one line before
|
|
// returning. An empty body would otherwise let a regression slip
|
|
// through this test silently.
|
|
require.NotEmpty(t, lines, "expected at least the first batch line to be written before ctx-cancel takes effect")
|
|
|
|
// No line should decode as a success trailer. This is robust to a
|
|
// truncated body, an empty firstBatch path, or an earlier exit before
|
|
// any batch is written.
|
|
for i, line := range lines {
|
|
var trailer searchTrailer
|
|
if err := json.Unmarshal(line, &trailer); err == nil {
|
|
require.NotEqual(t, "success", trailer.Status,
|
|
"line %d is a success trailer but ctx was cancelled before iteration began: %s", i, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fixedSearchQuerier returns a caller-provided SearchResultSet for both label
|
|
// search methods. It's used to construct controlled failure modes in tests.
|
|
type fixedSearchQuerier struct {
|
|
errorTestQuerier
|
|
rs storage.SearchResultSet
|
|
}
|
|
|
|
func (q fixedSearchQuerier) SearchLabelNames(context.Context, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
return q.rs
|
|
}
|
|
|
|
func (q fixedSearchQuerier) SearchLabelValues(context.Context, string, *storage.SearchHints, ...*labels.Matcher) storage.SearchResultSet {
|
|
return q.rs
|
|
}
|
|
|
|
// TestSearchStreamFirstBatchError exercises the boundary where the first
|
|
// batch that streamSearchResults assembles already contains both partial
|
|
// results and a tail error. The handler must stream the partial results
|
|
// before terminating with an in-band NDJSON error line.
|
|
func TestSearchStreamFirstBatchError(t *testing.T) {
|
|
rs := storage.NewSearchResultSetFromSliceAndError(
|
|
[]storage.SearchResult{
|
|
{Value: "alpha", Score: 1.0},
|
|
{Value: "beta", Score: 1.0},
|
|
},
|
|
nil,
|
|
errors.New("tail failure"),
|
|
)
|
|
queryable := errorTestQueryable{q: fixedSearchQuerier{rs: rs}}
|
|
|
|
api := minimalSearchAPI()
|
|
api.Queryable = queryable
|
|
|
|
// batch_size=5 ensures both results land in firstBatch; the iterator
|
|
// then surfaces the tail error in the same nextBatch call.
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"batch_size": []string{"5"},
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code, "partial first-batch results must not be lost to a JSON error response")
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
require.GreaterOrEqual(t, len(lines), 2, "expected a batch line followed by an error line")
|
|
|
|
var firstBatch searchBatch[searchMetricNameResult]
|
|
require.NoError(t, json.Unmarshal(lines[0], &firstBatch))
|
|
names := make([]string, 0, len(firstBatch.Results))
|
|
for _, r := range firstBatch.Results {
|
|
names = append(names, r.Name)
|
|
}
|
|
require.Equal(t, []string{"alpha", "beta"}, names)
|
|
|
|
var lastTrailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &lastTrailer))
|
|
require.Equal(t, "error", lastTrailer.Status, "stream must terminate with an error line, not a success trailer")
|
|
}
|
|
|
|
// TestStreamSearchResultsWarningsSorted verifies that warnings emitted in the
|
|
// first NDJSON batch are sorted (so the on-wire order is deterministic) and
|
|
// that the trailer's order-independent dedup correctly omits them when the
|
|
// warning set is unchanged at iteration end.
|
|
func TestStreamSearchResultsWarningsSorted(t *testing.T) {
|
|
var warns annotations.Annotations
|
|
// Add in non-alphabetical order to stress the map-iteration behaviour.
|
|
warns.Add(errors.New("c-warn"))
|
|
warns.Add(errors.New("a-warn"))
|
|
warns.Add(errors.New("b-warn"))
|
|
|
|
results := []storage.SearchResult{
|
|
{Value: "alpha", Score: 1.0},
|
|
{Value: "beta", Score: 0.9},
|
|
}
|
|
rs := storage.NewSearchResultSetFromSlice(results, warns)
|
|
|
|
api := newSearchTestAPI(t)
|
|
rec := httptest.NewRecorder()
|
|
// httptest.NewRecorder implements http.Flusher; streamSearchResults
|
|
// requires that.
|
|
sp := searchParams{limit: 100, batchSize: 100}
|
|
|
|
streamSearchResults(t.Context(), api, rec, rs, sp, func(sr storage.SearchResult) string {
|
|
return sr.Value
|
|
})
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
require.GreaterOrEqual(t, len(lines), 2, "expected at least a batch and a trailer line")
|
|
|
|
var batch searchBatch[string]
|
|
require.NoError(t, json.Unmarshal(lines[0], &batch))
|
|
require.Equal(t, []string{"a-warn", "b-warn", "c-warn"}, batch.Warnings,
|
|
"first batch must carry warnings in sorted order so the on-wire format is deterministic")
|
|
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.Equal(t, "success", trailer.Status)
|
|
require.Empty(t, trailer.Warnings,
|
|
"trailer must not re-emit warnings already carried by the first batch")
|
|
}
|
|
|
|
// warningsOnExhaustResultSet is a SearchResultSet that returns base warnings
|
|
// throughout iteration and additionally surfaces extra warnings once Next
|
|
// has returned false. It models a merge tree where a secondary querier's
|
|
// error becomes a warning only at exhaustion.
|
|
type warningsOnExhaustResultSet struct {
|
|
inner storage.SearchResultSet
|
|
extra annotations.Annotations
|
|
finished bool
|
|
}
|
|
|
|
func (s *warningsOnExhaustResultSet) Next() bool {
|
|
ok := s.inner.Next()
|
|
if !ok {
|
|
s.finished = true
|
|
}
|
|
return ok
|
|
}
|
|
|
|
func (s *warningsOnExhaustResultSet) At() storage.SearchResult { return s.inner.At() }
|
|
func (s *warningsOnExhaustResultSet) Err() error { return s.inner.Err() }
|
|
func (s *warningsOnExhaustResultSet) Close() error { return s.inner.Close() }
|
|
|
|
func (s *warningsOnExhaustResultSet) Warnings() annotations.Annotations {
|
|
base := s.inner.Warnings()
|
|
if !s.finished {
|
|
return base
|
|
}
|
|
// Return a fresh map so the inner's warnings are not mutated.
|
|
merged := annotations.Annotations{}
|
|
merged.Merge(base)
|
|
merged.Merge(s.extra)
|
|
return merged
|
|
}
|
|
|
|
// TestStreamSearchResultsWarningsTrailerDiff covers the "warnings changed
|
|
// after iteration" path: warnings present at the first-batch snapshot must
|
|
// not duplicate, and warnings surfacing only at exhaustion must reach the
|
|
// trailer.
|
|
func TestStreamSearchResultsWarningsTrailerDiff(t *testing.T) {
|
|
var base annotations.Annotations
|
|
base.Add(errors.New("base-warn"))
|
|
var extra annotations.Annotations
|
|
extra.Add(errors.New("late-warn"))
|
|
|
|
results := []storage.SearchResult{
|
|
{Value: "alpha"},
|
|
{Value: "beta"},
|
|
{Value: "gamma"},
|
|
}
|
|
rs := &warningsOnExhaustResultSet{
|
|
inner: storage.NewSearchResultSetFromSlice(results, base),
|
|
extra: extra,
|
|
}
|
|
|
|
api := newSearchTestAPI(t)
|
|
rec := httptest.NewRecorder()
|
|
// batch_size=1 forces iteration past the first batch so the first-batch
|
|
// warnings snapshot is taken before the inner is exhausted.
|
|
sp := searchParams{limit: 100, batchSize: 1}
|
|
|
|
streamSearchResults(t.Context(), api, rec, rs, sp, func(sr storage.SearchResult) string {
|
|
return sr.Value
|
|
})
|
|
|
|
lines := parseNDJSON(t, rec.Body.String())
|
|
// batch_size=1 with 3 results emits three batch lines, then the trailer.
|
|
require.Len(t, lines, 4, "expected three batch lines plus a trailer")
|
|
|
|
var firstBatch searchBatch[string]
|
|
require.NoError(t, json.Unmarshal(lines[0], &firstBatch))
|
|
require.Equal(t, []string{"base-warn"}, firstBatch.Warnings,
|
|
"first batch must see only the warnings present before iteration exhausts")
|
|
|
|
var trailer searchTrailer
|
|
require.NoError(t, json.Unmarshal(lines[len(lines)-1], &trailer))
|
|
require.Equal(t, "success", trailer.Status)
|
|
require.Equal(t, []string{"base-warn", "late-warn"}, trailer.Warnings,
|
|
"trailer must surface warnings that appeared only after iteration exhausted")
|
|
}
|
|
|
|
// TestSearchParamsRejectsExcessSearchTerms ensures that a request padded
|
|
// with more than maxSearchTermsPerRequest search[] params is rejected
|
|
// before any filter chain is built — preventing the quadratic per-value
|
|
// cost of an unbounded term count.
|
|
func TestSearchParamsRejectsExcessSearchTerms(t *testing.T) {
|
|
api := newSearchTestAPI(t)
|
|
|
|
tooMany := make([]string, maxSearchTermsPerRequest+1)
|
|
for i := range tooMany {
|
|
tooMany[i] = "t"
|
|
}
|
|
|
|
rec := doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": tooMany,
|
|
})
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
|
|
var response Response
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
|
require.Equal(t, statusError, response.Status)
|
|
require.Equal(t, errorBadData.str, response.ErrorType)
|
|
require.Contains(t, response.Error, "too many search[] terms")
|
|
|
|
// At the cap the request must still succeed.
|
|
atCap := make([]string, maxSearchTermsPerRequest)
|
|
for i := range atCap {
|
|
atCap[i] = "t"
|
|
}
|
|
rec = doSearchRequest(t, api, "/search/metric_names", url.Values{
|
|
"search[]": atCap,
|
|
})
|
|
require.Equal(t, http.StatusOK, rec.Code,
|
|
"a request with exactly maxSearchTermsPerRequest terms must be accepted")
|
|
}
|