From e1f4380b2ae6dfcb008dfce773c70d37c39333b7 Mon Sep 17 00:00:00 2001 From: Julien <291750+roidelapluie@users.noreply.github.com> Date: Tue, 19 May 2026 13:58:00 +0200 Subject: [PATCH] web/api: add search API endpoint (#18573) Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com> --- cmd/prometheus/main.go | 13 +- cmd/prometheus/testdata/features.json | 3 + docs/command-line/prometheus.md | 3 +- docs/feature_flags.md | 18 + docs/querying/api.md | 108 ++ storage/generic.go | 333 ++++- storage/generic_test.go | 329 +++++ storage/merge_test.go | 67 +- web/api/v1/api.go | 16 + web/api/v1/errors_test.go | 2 + web/api/v1/helpers_test.go | 2 + web/api/v1/openapi.go | 3 + web/api/v1/openapi_examples.go | 88 ++ web/api/v1/openapi_helpers.go | 65 + web/api/v1/openapi_paths.go | 119 ++ web/api/v1/openapi_schemas.go | 79 ++ web/api/v1/search.go | 842 ++++++++++++ web/api/v1/search_filters.go | 324 +++++ web/api/v1/search_filters_bench_test.go | 118 ++ web/api/v1/search_filters_test.go | 587 +++++++++ web/api/v1/search_test.go | 1282 +++++++++++++++++++ web/api/v1/testdata/openapi_3.1_golden.yaml | 935 ++++++++++++++ web/api/v1/testdata/openapi_3.2_golden.yaml | 935 ++++++++++++++ web/web.go | 8 + 24 files changed, 6225 insertions(+), 54 deletions(-) create mode 100644 storage/generic_test.go create mode 100644 web/api/v1/search.go create mode 100644 web/api/v1/search_filters.go create mode 100644 web/api/v1/search_filters_bench_test.go create mode 100644 web/api/v1/search_filters_test.go create mode 100644 web/api/v1/search_test.go diff --git a/cmd/prometheus/main.go b/cmd/prometheus/main.go index 7370cc3dac..f70d79bc81 100644 --- a/cmd/prometheus/main.go +++ b/cmd/prometheus/main.go @@ -337,6 +337,9 @@ func (c *flagConfig) setFeatureListOptions(logger *slog.Logger) error { case "fast-startup": c.tsdb.EnableFastStartup = true logger.Info("Experimental fast startup is enabled.") + case "search-api": + c.web.EnableSearch = true + logger.Info("Experimental search API enabled.") default: logger.Warn("Unknown option for --enable-feature", "option", o) } @@ -583,6 +586,9 @@ func main() { serverOnlyFlag(a, "storage.remote.read-max-bytes-in-frame", "Maximum number of bytes in a single frame for streaming remote read response types before marshalling. Note that client might have limit on frame size as well. 1MB as recommended by protobuf by default."). Default("1048576").IntVar(&cfg.web.RemoteReadBytesInFrame) + serverOnlyFlag(a, "web.search.max-limit", "Hard upper bound on the \"limit\" query parameter accepted by the experimental search API (--enable-feature=search-api). Requests with a higher limit are rejected with HTTP 400. 0 disables the cap."). + Default("10000").IntVar(&cfg.web.MaxSearchLimit) + serverOnlyFlag(a, "rules.alert.for-outage-tolerance", "Max time to tolerate prometheus outage for restoring \"for\" state of alert."). Default("1h").SetValue(&cfg.outageTolerance) @@ -625,7 +631,7 @@ func main() { a.Flag("scrape.discovery-reload-interval", "Interval used by scrape manager to throttle target groups updates."). Hidden().Default("5s").SetValue(&cfg.scrape.DiscoveryReloadInterval) - a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, extra-scrape-metrics, memory-snapshot-on-shutdown, metadata-wal-records, old-ui, otlp-deltatocumulative, otlp-native-delta-ingestion, promql-binop-fill-modifiers, promql-delayed-name-removal, promql-duration-expr, promql-experimental-functions, promql-extended-range-selectors, promql-per-step-stats, st-storage, st-synthesis, type-and-unit-labels, use-start-timestamps, use-uncached-io, xor2-encoding. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details."). + a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, extra-scrape-metrics, memory-snapshot-on-shutdown, metadata-wal-records, old-ui, otlp-deltatocumulative, otlp-native-delta-ingestion, promql-binop-fill-modifiers, promql-delayed-name-removal, promql-duration-expr, promql-experimental-functions, promql-extended-range-selectors, promql-per-step-stats, search-api, st-storage, st-synthesis, type-and-unit-labels, use-start-timestamps, use-uncached-io, xor2-encoding. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details."). StringsVar(&cfg.featureList) a.Flag("agent", "Run Prometheus in 'Agent mode'.").BoolVar(&agentMode) @@ -668,6 +674,11 @@ func main() { logger.Info("Automatic configuration file reloading enabled", "interval", cfg.autoReloadInterval) } + if cfg.web.MaxSearchLimit < 0 { + fmt.Fprintf(os.Stderr, "--web.search.max-limit must be non-negative; got %d (use 0 to disable the cap)\n", cfg.web.MaxSearchLimit) + os.Exit(1) + } + promqlParser := parser.NewParser(cfg.parserOpts) if agentMode && len(serverOnlyFlags) > 0 { diff --git a/cmd/prometheus/testdata/features.json b/cmd/prometheus/testdata/features.json index 78c6b5bdc8..e530770f8b 100644 --- a/cmd/prometheus/testdata/features.json +++ b/cmd/prometheus/testdata/features.json @@ -10,6 +10,9 @@ "query_stats": true, "query_warnings": true, "remote_write_receiver": false, + "search": false, + "search_fuzz_alg_jarowinkler": true, + "search_fuzz_alg_subsequence": true, "time_range_labels": true, "time_range_series": true }, diff --git a/docs/command-line/prometheus.md b/docs/command-line/prometheus.md index 0e59126905..b518ab209c 100644 --- a/docs/command-line/prometheus.md +++ b/docs/command-line/prometheus.md @@ -51,6 +51,7 @@ The Prometheus monitoring server | --storage.remote.read-sample-limit | Maximum overall number of samples to return via the remote read interface, in a single query. 0 means no limit. This limit is ignored for streamed response types. Use with server mode only. | `5e7` | | --storage.remote.read-concurrent-limit | Maximum number of concurrent remote read calls. 0 means no limit. Use with server mode only. | `10` | | --storage.remote.read-max-bytes-in-frame | Maximum number of bytes in a single frame for streaming remote read response types before marshalling. Note that client might have limit on frame size as well. 1MB as recommended by protobuf by default. Use with server mode only. | `1048576` | +| --web.search.max-limit | Hard upper bound on the "limit" query parameter accepted by the experimental search API (--enable-feature=search-api). Requests with a higher limit are rejected with HTTP 400. 0 disables the cap. Use with server mode only. | `10000` | | --rules.alert.for-outage-tolerance | Max time to tolerate prometheus outage for restoring "for" state of alert. Use with server mode only. | `1h` | | --rules.alert.for-grace-period | Minimum duration between alert and restored "for" state. This is maintained only for alerts with configured "for" time greater than grace period. Use with server mode only. | `10m` | | --rules.alert.resend-delay | Minimum amount of time to wait before resending an alert to Alertmanager. Use with server mode only. | `1m` | @@ -62,7 +63,7 @@ The Prometheus monitoring server | --query.timeout | Maximum time a query may take before being aborted. Use with server mode only. | `2m` | | --query.max-concurrency | Maximum number of queries executed concurrently. Use with server mode only. | `20` | | --query.max-samples | Maximum number of samples a single query can load into memory. Note that queries will fail if they try to load more samples than this into memory, so this also limits the number of samples a query can return. Use with server mode only. | `50000000` | -| --enable-feature ... | Comma separated feature names to enable. Valid options: concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, extra-scrape-metrics, memory-snapshot-on-shutdown, metadata-wal-records, old-ui, otlp-deltatocumulative, otlp-native-delta-ingestion, promql-binop-fill-modifiers, promql-delayed-name-removal, promql-duration-expr, promql-experimental-functions, promql-extended-range-selectors, promql-per-step-stats, st-storage, st-synthesis, type-and-unit-labels, use-start-timestamps, use-uncached-io, xor2-encoding. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | | +| --enable-feature ... | Comma separated feature names to enable. Valid options: concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, extra-scrape-metrics, memory-snapshot-on-shutdown, metadata-wal-records, old-ui, otlp-deltatocumulative, otlp-native-delta-ingestion, promql-binop-fill-modifiers, promql-delayed-name-removal, promql-duration-expr, promql-experimental-functions, promql-extended-range-selectors, promql-per-step-stats, search-api, st-storage, st-synthesis, type-and-unit-labels, use-start-timestamps, use-uncached-io, xor2-encoding. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | | | --agent | Run Prometheus in 'Agent mode'. | | | --log.level | Only log messages with the given severity or above. One of: [debug, info, warn, error] | `info` | | --log.format | Output format of log messages. One of: [logfmt, json] | `logfmt` | diff --git a/docs/feature_flags.md b/docs/feature_flags.md index 7218496594..5304a30922 100644 --- a/docs/feature_flags.md +++ b/docs/feature_flags.md @@ -401,3 +401,21 @@ Example query: ``` See [the fill modifiers documentation](querying/operators.md#filling-in-missing-matches) for more details and examples. + + +## Search API + +`--enable-feature=search-api` + +Enables the experimental search API endpoints for discovering metric names, +label names, and label values with fuzzy matching and filtering support. See +[the search API documentation](querying/api.md#searching-metric-names-label-names-and-label-values) +for details. + +The `--web.search.max-limit` flag (default `10000`) bounds the `limit` query +parameter accepted by the search endpoints. Requests with a higher `limit` are +rejected with HTTP 400. The default response limit (100) is silently clamped +to this maximum, so an operator setting a smaller cap does not break +no-`limit` requests. Setting the flag to `0` disables the cap entirely; this +is **not recommended** for endpoints exposed beyond a trusted network because a +single client can then request the entire index in one response. diff --git a/docs/querying/api.md b/docs/querying/api.md index 25d17ba093..238762d799 100644 --- a/docs/querying/api.md +++ b/docs/querying/api.md @@ -521,6 +521,114 @@ curl http://localhost:9090/api/v1/label/U__http_2e_status_code/values } ``` +### Searching metric names, label names, and label values + +These endpoints are experimental and must be enabled with +`--enable-feature=search-api`. + +The following endpoints provide streamed discovery results for metric names, +label names, and label values: + +``` +GET /api/v1/search/metric_names +POST /api/v1/search/metric_names +GET /api/v1/search/label_names +POST /api/v1/search/label_names +GET /api/v1/search/label_values +POST /api/v1/search/label_values +``` + +These endpoints return newline-delimited JSON with content type +`application/x-ndjson`. The stream contract is: + +- Zero or more **batch lines**, each with a `results` array and an optional + `warnings` array. +- The stream then ends with **either** a **trailer line** (`status`, `has_more`, + optional `warnings`) **or** an **error line** (`status`, `errorType`, `error`) + if iteration failed mid-stream after the first batch was sent. + +```json +{"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} +{"status":"success","has_more":false} +``` + +If an error occurs **before** streaming starts, the API returns the usual +Prometheus JSON error object with a 4xx/5xx status code. If an error occurs +**after** streaming starts, the stream ends with an NDJSON error line in place +of the trailer. + +Clients must tolerate an abrupt EOF without a trailer (for example, on +transport failure or server shutdown) and must ignore unknown fields in the +trailer for forward compatibility. + +The `has_more` field in the trailer is informational only: this version of +the API does not provide a pagination cursor. To retrieve more results, raise +`limit` (subject to the operator-configured `--web.search.max-limit`) or narrow +the request via `match[]`. A future version of the API may add a cursor. + +Common URL query parameters: + +- `match[]=`: Repeated series selector used to scope the + search. Optional. +- `search[]=`: Repeated search string matched against names or values. + Multiple values use OR semantics. Optional. +- `fuzz_threshold=`: Fuzzy threshold from 0 to 100. Optional. A value + of 0 is the lowest fuzzy threshold. +- `fuzz_alg=`: Matching algorithm. Optional. Default + is `subsequence`. +- `case_sensitive=`: Toggle case-sensitive matching. Optional. +- `sort_by=`: Sort mode. Supported values depend on the endpoint. +- `sort_dir=`: Sort direction. Optional. Only valid with + `sort_by=alpha`. +- `include_score=`: Include the relevance score in each result. Optional. +- `start=`: Start timestamp. Optional. +- `end=`: End timestamp. Optional. +- `limit=`: Maximum number of returned results. Optional. Default is + 100. +- `batch_size=`: Preferred number of results per NDJSON batch. + Optional. Default is 100. + +The `start` and `end` parameters narrow results to the selected time window. +Results may include values from series active slightly outside that window, +because Prometheus stores data in fixed-size blocks (typically 2 hours each). + +Additional parameters for `/api/v1/search/metric_names`: + +- `include_metadata=`: Include metric metadata in each result. +- `sort_by=` + +Additional parameters for `/api/v1/search/label_names`: + +- `sort_by=` + +Additional parameters for `/api/v1/search/label_values`: + +- `label=`: Label name whose values should be searched. Required. +- `sort_by=` + +This example searches metric names for autocomplete: + +```bash +curl -g 'http://localhost:9090/api/v1/search/metric_names?search[]=http_req&sort_by=score&include_metadata=true&limit=5' +``` + +```json +{"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} +{"status":"success","has_more":false} +``` + +This example searches label values for the `instance` label within the `up` +metric: + +```bash +curl -g 'http://localhost:9090/api/v1/search/label_values?label=instance&match[]=up&search[]=909&sort_by=score' +``` + +```json +{"results":[{"value":"localhost:9090"},{"value":"localhost:9091"}]} +{"status":"success","has_more":true} +``` + ## Querying exemplars This is **experimental** and might change in the future. diff --git a/storage/generic.go b/storage/generic.go index 1fbd960959..0e9431d404 100644 --- a/storage/generic.go +++ b/storage/generic.go @@ -190,31 +190,147 @@ func NewSearchResultSetFromSlice(results []SearchResult, warns annotations.Annot return &sliceSearchResultSet{results: results, warnings: warns, idx: -1} } +// errAfterSliceSet yields a fixed slice of results and then surfaces err once +// the slice is exhausted. It is the partial-results-then-error counterpart to +// sliceSearchResultSet. +type errAfterSliceSet struct { + results []SearchResult + warnings annotations.Annotations + idx int + err error +} + +func (s *errAfterSliceSet) Next() bool { + s.idx++ + return s.idx < len(s.results) +} + +func (s *errAfterSliceSet) At() SearchResult { return s.results[s.idx] } + +func (s *errAfterSliceSet) Warnings() annotations.Annotations { return s.warnings } + +func (s *errAfterSliceSet) Err() error { + if s.idx >= len(s.results) { + return s.err + } + return nil +} + +func (*errAfterSliceSet) Close() error { return nil } + +// NewSearchResultSetFromSliceAndError returns a SearchResultSet that iterates +// the given slice and, once exhausted, exposes err via Err(). Any warnings +// accumulated by the upstream are surfaced via Warnings(). It models a backend +// that produced partial output and warnings before its underlying iterator +// failed, which is common when a remote source returns a stream that aborts +// mid-flight. Callers should use this rather than ad-hoc test fakes so the +// error-surfacing behaviour stays consistent with the merge contract. +func NewSearchResultSetFromSliceAndError(results []SearchResult, warns annotations.Annotations, err error) SearchResultSet { + return &errAfterSliceSet{results: results, warnings: warns, err: err, idx: -1} +} + +// minLinearAllocCap is the floor for the linear-path result capacity hint when +// a Filter is active. It avoids degenerate growth for tiny limits while still +// keeping the upfront allocation small for sparse matches against large indices. +const minLinearAllocCap = 256 + // ApplySearchHints filters, sorts, and limits a slice of string values according to hints, // returning scored SearchResult entries. A nil hints value is treated as the zero value. // The input values slice is assumed to be ordered ascending by value; the function only // performs extra work for orderings that differ from this. +// +// Allocation and ordering are tuned to the (Filter, OrderBy, Limit) combination: +// - Filter == nil: at most Limit entries are copied; OrderByValueDesc walks the input +// in reverse so we never materialise the full slice when limited. +// - OrderByValueAsc + Filter + Limit: stream-filter with early exit at Limit matches. +// - OrderByValueDesc + Filter + Limit: reverse stream-filter with early exit at Limit +// matches, taking advantage of the input being ascending so the tail is the largest. +// - OrderByScoreDesc + Filter + Limit: top-K min-heap of size Limit, avoiding a full +// sort over the matched set. +// - Other combinations fall back to filter-then-reorder-then-slice, with the upfront +// capacity capped by min(len(values), max(2*Limit, minLinearAllocCap)). func ApplySearchHints(values []string, hints *SearchHints) []SearchResult { if hints == nil { hints = &SearchHints{} } - results := make([]SearchResult, 0, len(values)) + if hints.Filter == nil { + return applySearchHintsNoFilter(values, hints) + } + if hints.Limit > 0 { + switch hints.OrderBy { + case OrderByScoreDesc: + return topKByScore(values, hints.Filter, hints.Limit) + case OrderByValueDesc: + return reverseFilterEarlyExit(values, hints.Filter, hints.Limit) + } + } + return applySearchHintsLinear(values, hints) +} + +// reverseFilterEarlyExit walks the input ascending-sorted slice from the tail, +// accepting up to limit matches. Because the input is ascending, the tail +// holds the lex-largest entries, so iterating in reverse yields results in +// descending order without an extra sort. +func reverseFilterEarlyExit(values []string, filter Filter, limit int) []SearchResult { + results := make([]SearchResult, 0, min(limit, len(values))) + for i := len(values) - 1; i >= 0 && len(results) < limit; i-- { + accepted, score := filter.Accept(values[i]) + if !accepted { + continue + } + results = append(results, SearchResult{Value: values[i], Score: score}) + } + return results +} + +// applySearchHintsNoFilter handles the unfiltered path: scores are uniformly 1.0 +// and at most Limit entries are emitted in the requested order. +func applySearchHintsNoFilter(values []string, hints *SearchHints) []SearchResult { + n := len(values) + if hints.Limit > 0 && hints.Limit < n { + n = hints.Limit + } + results := make([]SearchResult, 0, n) + if hints.OrderBy == OrderByValueDesc { + // Walk the input in reverse so we keep the largest-Value entries + // without materialising the full slice when limited. The i >= 0 + // guard is defensive: n is clamped to len(values) above, so we + // should always exit on len(results) == n first. + for i := len(values) - 1; i >= 0 && len(results) < n; i-- { + results = append(results, SearchResult{Value: values[i], Score: 1.0}) + } + return results + } + // OrderByValueAsc and OrderByScoreDesc both reduce to value-ascending here: + // uniform scores tie-break on Value asc under (Score desc, Value asc). + for i := range n { + results = append(results, SearchResult{Value: values[i], Score: 1.0}) + } + return results +} + +// applySearchHintsLinear handles the filtered path for orderings other than +// OrderByScoreDesc-with-limit (which uses top-K). It streams the filter and, +// for OrderByValueAsc with a limit, exits as soon as the limit is reached. +func applySearchHintsLinear(values []string, hints *SearchHints) []SearchResult { + results := make([]SearchResult, 0, linearResultCap(len(values), hints.Limit)) + earlyExit := hints.OrderBy == OrderByValueAsc && hints.Limit > 0 for _, v := range values { - if hints.Filter != nil { - accepted, score := hints.Filter.Accept(v) - if accepted { - results = append(results, SearchResult{Value: v, Score: score}) - } - } else { - results = append(results, SearchResult{Value: v, Score: 1.0}) + accepted, score := hints.Filter.Accept(v) + if !accepted { + continue + } + results = append(results, SearchResult{Value: v, Score: score}) + if earlyExit && len(results) >= hints.Limit { + break } } switch hints.OrderBy { - case OrderByValueAsc: - // Input is already ascending; nothing to do. case OrderByValueDesc: slices.Reverse(results) case OrderByScoreDesc: + // Reached only when Limit == 0; ApplySearchHints routes + // OrderByScoreDesc + Limit > 0 to topKByScore instead. slices.SortFunc(results, compareSearchResults(OrderByScoreDesc)) } if hints.Limit > 0 && len(results) > hints.Limit { @@ -223,6 +339,141 @@ func ApplySearchHints(values []string, hints *SearchHints) []SearchResult { return results } +// linearResultCap returns the upfront capacity hint for the linear-path result +// slice. We cannot know the filter selectivity ahead of time, so we use 2*Limit +// as a heuristic for the expected match count and floor it at minLinearAllocCap; +// it is always bounded by len(values). +func linearResultCap(numValues, limit int) int { + if limit <= 0 { + return numValues + } + // Defensive overflow guard: 2*limit can wrap when an untrusted limit + // value is in the upper int range (only reachable when the operator + // disabled --web.search.max-limit). Fall back to the small-allocation + // floor instead of numValues so a sparse filter does not cause a + // multi-MB upfront allocation; append amortizes the growth from + // there. + allocCap := 2 * limit + if allocCap < limit { + return minLinearAllocCap + } + if allocCap < minLinearAllocCap { + allocCap = minLinearAllocCap + } + if allocCap > numValues { + allocCap = numValues + } + return allocCap +} + +// topKByScore returns the top-K matches under the (Score desc, Value asc) total +// order, using a min-heap of size limit. This avoids sorting the full matched +// set when only the best Limit results are needed. +// +// The heap is a small typed structure (no container/heap interface) so each +// candidate replacement is a direct struct write rather than an interface +// box. This keeps the hot loop allocation-free past the initial fill. +func topKByScore(values []string, filter Filter, limit int) []SearchResult { + h := make(searchTopKHeap, 0, min(limit, len(values))) + for _, v := range values { + accepted, score := filter.Accept(v) + if !accepted { + continue + } + if len(h) < limit { + h = h.push(SearchResult{Value: v, Score: score}) + continue + } + // The heap minimum is the worst entry currently kept. Three cases: + // - Lower score: cannot improve, skip without a string compare. + // - Higher score: definitely better, replace. + // - Tied score: keep the lex-smaller Value. + worst := h[0] + switch { + case score < worst.Score: + continue + case score > worst.Score: + h[0] = SearchResult{Value: v, Score: score} + h.siftDown(0) + case v < worst.Value: + h[0] = SearchResult{Value: v, Score: score} + h.siftDown(0) + } + } + out := make([]SearchResult, len(h)) + // Pop returns worst-first under our heap order; place results from the tail + // so the final slice is best-first (Score desc, Value asc). + for i := len(out) - 1; i >= 0; i-- { + var r SearchResult + r, h = h.pop() + out[i] = r + } + return out +} + +// searchTopKHeap is a typed binary min-heap under the inverse of the (Score +// desc, Value asc) total order, so heap[0] is the worst entry currently kept. +// Replacing the minimum on better candidates keeps the K best entries without +// a full sort and without the per-operation interface boxing that +// container/heap would introduce. +type searchTopKHeap []SearchResult + +// less reports whether index i should sift above index j in the heap. The +// "lighter" entry (lower score, or higher Value on ties) sits at the root. +func (h searchTopKHeap) less(i, j int) bool { + if h[i].Score != h[j].Score { + return h[i].Score < h[j].Score + } + return h[i].Value > h[j].Value +} + +func (h searchTopKHeap) push(r SearchResult) searchTopKHeap { + h = append(h, r) + h.siftUp(len(h) - 1) + return h +} + +func (h searchTopKHeap) pop() (SearchResult, searchTopKHeap) { + n := len(h) - 1 + out := h[0] + h[0] = h[n] + h = h[:n] + if n > 0 { + h.siftDown(0) + } + return out, h +} + +func (h searchTopKHeap) siftUp(i int) { + for i > 0 { + parent := (i - 1) / 2 + if !h.less(i, parent) { + return + } + h[i], h[parent] = h[parent], h[i] + i = parent + } +} + +func (h searchTopKHeap) siftDown(i int) { + n := len(h) + for { + left := 2*i + 1 + if left >= n { + return + } + smallest := left + if right := left + 1; right < n && h.less(right, left) { + smallest = right + } + if !h.less(smallest, i) { + return + } + h[i], h[smallest] = h[smallest], h[i] + i = smallest + } +} + // compareSearchResults returns the total-order comparison function for the // given Ordering. For OrderByValueAsc and OrderByValueDesc the order is on // Value alone. For OrderByScoreDesc the order is (Score desc, Value asc), @@ -274,6 +525,38 @@ func mergeSearchSets(hints *SearchHints, fn func(Searcher) SearchResultSet, sear return pairwiseMergeSearchSets(sets, order, limit) } +// MergeSearchResultSets merges pre-sorted SearchResultSets into a single set +// according to hints.OrderBy and hints.Limit. A nil hints value is treated as +// the zero value (OrderByValueAsc, no limit). +// +// All inputs must yield results in the requested order. Duplicates collapse in +// place: under value-based orderings the higher score wins; under +// OrderByScoreDesc the Searcher contract requires identical scores for a given +// Value, so duplicates tie on (Score, Value) and are adjacent. +// +// The returned set owns all inputs: the caller closes the returned set exactly +// once and must not close the inputs separately. If a single input errors, the +// merge keeps draining the surviving inputs and surfaces the joined error via +// Err() once iteration ends. +// +// MergeSearchResultSets does not lazily construct its inputs: each set in +// `sets` is taken as already opened, since SearchResultSet construction is +// the caller's responsibility. Callers that hold Searcher instances and want +// to defer SearchLabel* calls until the result is actually consumed should +// wrap each input in their own lazy SearchResultSet (the storage package +// uses an internal lazy wrapper for that path). +func MergeSearchResultSets(sets []SearchResultSet, hints *SearchHints) SearchResultSet { + var ( + order Ordering + limit int + ) + if hints != nil { + order = hints.OrderBy + limit = hints.Limit + } + return pairwiseMergeSearchSets(sets, order, limit) +} + // pairwiseMergeSearchSets recursively merges SearchResultSets in a balanced // binary tree. Each merge node respects the requested ordering and stops after // emitting limit results, enabling early termination that avoids consuming the @@ -295,6 +578,15 @@ func pairwiseMergeSearchSets(sets []SearchResultSet, order Ordering, limit int) } } +// NewLazySearchResultSet returns a SearchResultSet that defers calling init +// until the first Next, At, Warnings, Err, or Close. It is intended for +// callers of MergeSearchResultSets that want to amortize sub-query +// construction cost across a merge tree with an early-terminating limit: +// branches that the merge never pulls from incur no construction cost. +func NewLazySearchResultSet(init func() SearchResultSet) SearchResultSet { + return &lazySearchResultSet{init: init} +} + // lazySearchResultSet defers the creation of a SearchResultSet until the first // call to Next. This avoids invoking searchers whose results are never consumed. type lazySearchResultSet struct { @@ -370,6 +662,14 @@ func (s *limitSearchResultSet) Close() error { return s.rs. // that order. Equal entries (same Value under value orderings, same // (Score, Value) under OrderByScoreDesc) collapse in place; under value // orderings the higher score wins. +// +// Partial-error semantics: if one side returns Next()==false with a non-nil +// Err(), iteration does not terminate. The other side keeps draining until +// it too is exhausted, after which Err() returns errors.Join of any errors +// recorded on either side. This trades a strict fail-fast contract for the +// preservation of buffered results from the surviving side, which is the +// behaviour expected by callers that fan out queries across heterogeneous +// backends. type mergingSearchResultSet struct { a, b SearchResultSet cmpFn func(a, b SearchResult) int @@ -402,7 +702,11 @@ func (s *mergingSearchResultSet) Next() bool { return false } - // Prime both sides on first call. + // Prime both sides on first call. A side returning Next()=false here + // either ran clean out of values or surfaced an error; in either case + // we stop pulling from it but keep draining the other side. The error + // (if any) is reported via Err() once iteration ends, joined with the + // other side's error if it also fails. if !s.aInit { s.aOk = s.a.Next() if s.aOk { @@ -418,13 +722,6 @@ func (s *mergingSearchResultSet) Next() bool { s.bInit = true } - // Check for errors from either side after priming or after the - // previous advance. An error means we should stop iteration. - if s.a.Err() != nil || s.b.Err() != nil { - s.done = true - return false - } - switch { case !s.aOk && !s.bOk: s.done = true diff --git a/storage/generic_test.go b/storage/generic_test.go new file mode 100644 index 0000000000..489cf73bbd --- /dev/null +++ b/storage/generic_test.go @@ -0,0 +1,329 @@ +// 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 storage + +import ( + "errors" + "fmt" + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/util/annotations" +) + +// prefixScoreFilter accepts values starting with prefix and returns a score +// inversely proportional to the value length (shorter = higher score). Useful +// for asserting top-K behaviour where the filter produces a non-trivial +// distribution of scores. +type prefixScoreFilter struct{ prefix string } + +func (f prefixScoreFilter) Accept(v string) (bool, float64) { + if !strings.HasPrefix(v, f.prefix) { + return false, 0 + } + // Score in (0, 1], higher for shorter values. + return true, 1.0 / float64(1+len(v)-len(f.prefix)) +} + +// constScoreFilter accepts every value with the same score. It is used to +// exercise the OrderByScoreDesc tie-break on Value asc. +type constScoreFilter float64 + +func (f constScoreFilter) Accept(string) (bool, float64) { return true, float64(f) } + +func TestApplySearchHints(t *testing.T) { + values := []string{"alpha", "beta", "gamma", "delta", "epsilon"} + + t.Run("nil hints returns all with score 1", func(t *testing.T) { + got := ApplySearchHints(values, nil) + require.Len(t, got, len(values)) + for i, r := range got { + require.Equal(t, values[i], r.Value) + require.Equal(t, 1.0, r.Score) + } + }) + + t.Run("no filter ascending limit", func(t *testing.T) { + got := ApplySearchHints(values, &SearchHints{Limit: 3}) + require.Equal(t, []SearchResult{ + {Value: "alpha", Score: 1.0}, + {Value: "beta", Score: 1.0}, + {Value: "gamma", Score: 1.0}, + }, got) + }) + + t.Run("no filter descending limit walks input in reverse", func(t *testing.T) { + got := ApplySearchHints(values, &SearchHints{OrderBy: OrderByValueDesc, Limit: 2}) + require.Equal(t, []SearchResult{ + {Value: "epsilon", Score: 1.0}, + {Value: "delta", Score: 1.0}, + }, got) + }) + + t.Run("filter ascending early exit at limit", func(t *testing.T) { + hits := 0 + filter := FilterFunc(func(_ string) (bool, float64) { + hits++ + return true, 1.0 + }) + got := ApplySearchHints(values, &SearchHints{Filter: filter, Limit: 2}) + require.Len(t, got, 2) + // Early exit: the filter should not have been called for values past + // the second match. + require.Equal(t, 2, hits) + }) + + t.Run("filter descending reverses then limits", func(t *testing.T) { + got := ApplySearchHints(values, &SearchHints{ + Filter: prefixScoreFilter{prefix: ""}, // accepts everything. + OrderBy: OrderByValueDesc, + Limit: 2, + }) + require.Len(t, got, 2) + // Input order is values[0..4]; reverse picks epsilon then delta. + require.Equal(t, "epsilon", got[0].Value) + require.Equal(t, "delta", got[1].Value) + }) + + t.Run("filter descending limit early exits without scanning all", func(t *testing.T) { + // Counting filter that accepts every value. With early exit we + // should call Accept at most Limit times for ValueDesc + Filter. + hits := 0 + filter := FilterFunc(func(string) (bool, float64) { + hits++ + return true, 1.0 + }) + got := ApplySearchHints(values, &SearchHints{ + Filter: filter, + OrderBy: OrderByValueDesc, + Limit: 2, + }) + require.Equal(t, []SearchResult{ + {Value: "epsilon", Score: 1.0}, + {Value: "delta", Score: 1.0}, + }, got) + require.Equal(t, 2, hits, "must early-exit at limit for OrderByValueDesc") + }) + + t.Run("score desc top-K returns best K", func(t *testing.T) { + // Build a wide value set where score is inversely tied to length. + // Top-K of size 3 should pick the three shortest "x" values. + var input []string + for i := range 100 { + input = append(input, fmt.Sprintf("x%0*d", i+1, i)) + } + got := ApplySearchHints(input, &SearchHints{ + Filter: prefixScoreFilter{prefix: "x"}, + OrderBy: OrderByScoreDesc, + Limit: 3, + }) + require.Len(t, got, 3) + // Scores are non-increasing. + for i := 1; i < len(got); i++ { + require.GreaterOrEqual(t, got[i-1].Score, got[i].Score) + } + // Best score is the shortest value ("x0"). + require.Equal(t, "x0", got[0].Value) + }) + + t.Run("score desc tie-break on Value asc", func(t *testing.T) { + // With a constant score, ordering tie-breaks on Value ascending. + got := ApplySearchHints(values, &SearchHints{ + Filter: constScoreFilter(0.5), + OrderBy: OrderByScoreDesc, + Limit: 3, + }) + require.Equal(t, []SearchResult{ + {Value: "alpha", Score: 0.5}, + {Value: "beta", Score: 0.5}, + {Value: "delta", Score: 0.5}, + }, got) + }) + + t.Run("score desc unlimited keeps all matches sorted", func(t *testing.T) { + got := ApplySearchHints(values, &SearchHints{ + Filter: prefixScoreFilter{prefix: ""}, + OrderBy: OrderByScoreDesc, + }) + require.Len(t, got, len(values)) + for i := 1; i < len(got); i++ { + if got[i-1].Score == got[i].Score { + require.Less(t, got[i-1].Value, got[i].Value) + continue + } + require.Greater(t, got[i-1].Score, got[i].Score) + } + }) + + t.Run("near-MaxInt limit does not panic top-K", func(t *testing.T) { + // Reachable when --web.search.max-limit=0 disables the cap and a + // client supplies a near-MaxInt limit. The pre-allocation must + // clamp to len(values) rather than blowing up makeslice. + got := ApplySearchHints([]string{"alpha", "beta", "gamma"}, &SearchHints{ + Filter: FilterFunc(func(string) (bool, float64) { return true, 0.5 }), + OrderBy: OrderByScoreDesc, + Limit: math.MaxInt, + }) + require.Len(t, got, 3) + }) + + t.Run("near-MaxInt limit does not panic reverse early exit", func(t *testing.T) { + got := ApplySearchHints([]string{"alpha", "beta", "gamma"}, &SearchHints{ + Filter: FilterFunc(func(string) (bool, float64) { return true, 1.0 }), + OrderBy: OrderByValueDesc, + Limit: math.MaxInt, + }) + require.Equal(t, []SearchResult{ + {Value: "gamma", Score: 1.0}, + {Value: "beta", Score: 1.0}, + {Value: "alpha", Score: 1.0}, + }, got) + }) + + t.Run("near-MaxInt limit does not panic linear ascending path", func(t *testing.T) { + // Same DoS-shape input but routed through applySearchHintsLinear + // via OrderByValueAsc. linearResultCap must absorb the overflow + // without makeslice panicking. + got := ApplySearchHints([]string{"alpha", "beta", "gamma"}, &SearchHints{ + Filter: FilterFunc(func(string) (bool, float64) { return true, 0.5 }), + OrderBy: OrderByValueAsc, + Limit: math.MaxInt, + }) + require.Len(t, got, 3) + require.Equal(t, "alpha", got[0].Value) + require.Equal(t, "gamma", got[2].Value) + }) + + t.Run("filter selectivity 1 percent caps allocation", func(t *testing.T) { + // 10k values, 1% match. Without the alloc cap fix the result slice + // would be sized to 10k. We don't directly assert capacity here (it + // is implementation-defined), but exercising this path under -race + // catches regressions in the linear capacity helper. + var input []string + for i := range 10000 { + input = append(input, fmt.Sprintf("v%d", i)) + } + got := ApplySearchHints(input, &SearchHints{ + Filter: FilterFunc(func(v string) (bool, float64) { + // Match values whose decimal representation ends in "00". + return strings.HasSuffix(v, "00"), 0.5 + }), + Limit: 50, + }) + require.LessOrEqual(t, len(got), 50) + }) +} + +func TestNewSearchResultSetFromSliceAndError(t *testing.T) { + // Verify that the warns parameter round-trips through Warnings() and + // that Err() is hidden until the slice is exhausted, so callers see + // the partial-results-then-error shape the helper documents. + var warns annotations.Annotations + warns.Add(errors.New("upstream warning")) + + results := []SearchResult{ + {Value: "alpha", Score: 1.0}, + {Value: "beta", Score: 1.0}, + } + rs := NewSearchResultSetFromSliceAndError(results, warns, errors.New("tail failure")) + + var got []SearchResult + for rs.Next() { + got = append(got, rs.At()) + // Warnings are observable from the start, before iteration ends. + require.NotNil(t, rs.Warnings()) + // Err() must remain nil while the slice is still producing values. + require.NoError(t, rs.Err()) + } + require.Equal(t, results, got) + require.Error(t, rs.Err()) + require.Contains(t, rs.Err().Error(), "tail failure") + + // Warnings still reflect the upstream payload after iteration ended. + gotWarnings := rs.Warnings().AsErrors() + require.Len(t, gotWarnings, 1) + require.Contains(t, gotWarnings[0].Error(), "upstream warning") + + require.NoError(t, rs.Close()) +} + +func TestLinearResultCap(t *testing.T) { + // Boundary: limit smaller than the floor. + require.Equal(t, minLinearAllocCap, linearResultCap(10000, 10)) + // Limit zero means unlimited; cap grows to len. + require.Equal(t, 10000, linearResultCap(10000, 0)) + // Cap is capped by len(values). + require.Equal(t, 50, linearResultCap(50, 100)) + // 2*limit dominates when above the floor and below len. + require.Equal(t, 1000, linearResultCap(10000, 500)) + // Overflow fallback returns the small-allocation floor so a near-MaxInt + // limit does not pull a multi-MB upfront allocation when the filter + // would reject most of the input anyway. + require.Equal(t, minLinearAllocCap, linearResultCap(10000, math.MaxInt)) +} + +// FilterFunc adapts a function to the Filter interface for testing. +type FilterFunc func(string) (bool, float64) + +func (f FilterFunc) Accept(v string) (bool, float64) { return f(v) } + +func BenchmarkApplySearchHints(b *testing.B) { + const n = 100_000 + values := make([]string, n) + for i := range values { + values[i] = fmt.Sprintf("metric_%07d", i) + } + + cases := []struct { + name string + hints *SearchHints + }{ + {"NoFilter_NoLimit_Asc", &SearchHints{OrderBy: OrderByValueAsc}}, + {"NoFilter_Limit100_Asc", &SearchHints{OrderBy: OrderByValueAsc, Limit: 100}}, + {"NoFilter_Limit100_Desc", &SearchHints{OrderBy: OrderByValueDesc, Limit: 100}}, + {"Filter1pct_NoLimit_Asc", &SearchHints{Filter: FilterFunc(func(v string) (bool, float64) { + return strings.HasSuffix(v, "00"), 1.0 + })}}, + {"Filter1pct_Limit100_Asc", &SearchHints{Filter: FilterFunc(func(v string) (bool, float64) { + return strings.HasSuffix(v, "00"), 1.0 + }), Limit: 100}}, + {"Filter50pct_TopK100_ScoreDesc", &SearchHints{Filter: FilterFunc(func(v string) (bool, float64) { + // Diversify scores by hashing the trailing two characters + // so the heap exercises real reorderings, not just the + // tied-score replace path. + b0 := v[len(v)-1] + if b0&1 != 0 { + return false, 0 + } + b1 := v[len(v)-2] + score := float64(int(b0)*31+int(b1)%53) / 1024.0 + if score > 1.0 { + score = 1.0 + } + return true, score + }), OrderBy: OrderByScoreDesc, Limit: 100}}, + } + + for _, c := range cases { + b.Run(c.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _ = ApplySearchHints(values, c.hints) + } + }) + } +} diff --git a/storage/merge_test.go b/storage/merge_test.go index 2afc2caeb6..d0272dead4 100644 --- a/storage/merge_test.go +++ b/storage/merge_test.go @@ -1757,42 +1757,13 @@ type partialErrSearchQuerier struct { } func (q *partialErrSearchQuerier) SearchLabelNames(_ context.Context, _ *SearchHints, _ ...*labels.Matcher) SearchResultSet { - return newErrAfterResultsSet(q.names, q.err) + return NewSearchResultSetFromSliceAndError(q.names, nil, q.err) } func (q *partialErrSearchQuerier) SearchLabelValues(_ context.Context, _ string, _ *SearchHints, _ ...*labels.Matcher) SearchResultSet { - return newErrAfterResultsSet(q.values, q.err) + return NewSearchResultSetFromSliceAndError(q.values, nil, q.err) } -// errAfterResultsSet is a SearchResultSet that yields results then returns an error. -type errAfterResultsSet struct { - results []SearchResult - idx int // starts at -1; incremented by Next. - err error -} - -func newErrAfterResultsSet(results []SearchResult, err error) *errAfterResultsSet { - return &errAfterResultsSet{results: results, err: err, idx: -1} -} - -func (s *errAfterResultsSet) Next() bool { - s.idx++ - return s.idx < len(s.results) -} - -func (s *errAfterResultsSet) At() SearchResult { return s.results[s.idx] } - -func (*errAfterResultsSet) Warnings() annotations.Annotations { return nil } - -func (s *errAfterResultsSet) Err() error { - if s.idx >= len(s.results) { - return s.err - } - return nil -} - -func (*errAfterResultsSet) Close() error { return nil } - func collectSearchResults(t *testing.T, rs SearchResultSet) []SearchResult { t.Helper() var got []SearchResult @@ -1916,8 +1887,9 @@ func TestMergeQuerierSearch(t *testing.T) { // the caller closes before exhaustion. This cannot be tested // through the public API because the error-to-warning conversion // only fires on exhaustion. - inner := newErrAfterResultsSet( + inner := NewSearchResultSetFromSliceAndError( []SearchResult{{Value: "zone", Score: 0.5}}, + nil, errors.New("early close failure"), ) rs := warningsOnErrorSearchResultSet(inner) @@ -2051,7 +2023,7 @@ func TestMergeQuerierSearch(t *testing.T) { }, got) }) - t.Run("primary error preserves warnings from prior searchers", func(t *testing.T) { + t.Run("primary error preserves prior results and warnings", func(t *testing.T) { var ws annotations.Annotations ws.Add(errors.New("prior warning")) q1 := &searchQuerier{ @@ -2063,7 +2035,11 @@ func TestMergeQuerierSearch(t *testing.T) { defer merged.Close() rs := merged.(Searcher).SearchLabelNames(ctx, nil) - // Iteration should see no results because the merge errored. + // The successful searcher's results must come through even though + // the other primary failed; only after the surviving side exhausts + // does iteration end. + require.True(t, rs.Next()) + require.Equal(t, SearchResult{Value: "env", Score: 1.0}, rs.At()) require.False(t, rs.Next()) require.Error(t, rs.Err()) require.Contains(t, rs.Err().Error(), "primary failure") @@ -2074,6 +2050,29 @@ func TestMergeQuerierSearch(t *testing.T) { require.NoError(t, rs.Close()) }) + t.Run("partial primary failure mid-stream keeps surviving values", func(t *testing.T) { + // q1 yields two values then errors; q2 yields one value cleanly. + q1 := &partialErrSearchQuerier{ + names: []SearchResult{{Value: "alpha", Score: 1.0}, {Value: "beta", Score: 1.0}}, + err: errors.New("a tail error"), + } + q2 := &searchQuerier{ + names: []SearchResult{{Value: "delta", Score: 1.0}}, + } + merged := NewMergeQuerier([]Querier{q1, q2}, nil, ChainedSeriesMerge) + defer merged.Close() + + rs := merged.(Searcher).SearchLabelNames(ctx, nil) + var got []string + for rs.Next() { + got = append(got, rs.At().Value) + } + require.Equal(t, []string{"alpha", "beta", "delta"}, got) + require.Error(t, rs.Err()) + require.Contains(t, rs.Err().Error(), "a tail error") + require.NoError(t, rs.Close()) + }) + t.Run("limit is applied at merge level", func(t *testing.T) { q1 := &searchQuerier{ names: []SearchResult{{Value: "a", Score: 1.0}, {Value: "b", Score: 0.9}}, diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 955df918fa..d906e105d8 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -240,6 +240,9 @@ type API struct { db TSDBAdminStats dbDir string enableAdmin bool + enableSearch bool + maxSearchLimit int + metaCache *searchMetadataCache logger *slog.Logger CORSOrigin *regexp.Regexp buildInfo *PrometheusVersion @@ -280,6 +283,8 @@ func NewAPI( db TSDBAdminStats, dbDir string, enableAdmin bool, + enableSearch bool, + maxSearchLimit int, logger *slog.Logger, rr func(context.Context) RulesRetriever, remoteReadSampleLimit int, @@ -323,6 +328,9 @@ func NewAPI( db: db, dbDir: dbDir, enableAdmin: enableAdmin, + enableSearch: enableSearch, + maxSearchLimit: maxSearchLimit, + metaCache: &searchMetadataCache{}, rulesRetriever: rr, logger: logger, CORSOrigin: corsOrigin, @@ -468,6 +476,14 @@ func (api *API) Register(r *route.Router) { r.Post("/write", api.ready(api.remoteWrite)) r.Post("/otlp/v1/metrics", api.ready(api.otlpWrite)) + // Search endpoints. + r.Get("/search/metric_names", api.ready(api.searchMetricNames)) + r.Post("/search/metric_names", api.ready(api.searchMetricNames)) + r.Get("/search/label_names", api.ready(api.searchLabelNames)) + r.Post("/search/label_names", api.ready(api.searchLabelNames)) + r.Get("/search/label_values", api.ready(api.searchLabelValues)) + r.Post("/search/label_values", api.ready(api.searchLabelValues)) + r.Get("/alerts", wrapAgent(api.alerts)) r.Get("/rules", wrapAgent(api.rules)) diff --git a/web/api/v1/errors_test.go b/web/api/v1/errors_test.go index 6b246e7622..7e31b18a70 100644 --- a/web/api/v1/errors_test.go +++ b/web/api/v1/errors_test.go @@ -147,6 +147,8 @@ func createPrometheusAPI(t *testing.T, q storage.SampleAndChunkQueryable, overri nil, // Only needed for admin APIs. "", // This is for snapshots, which is disabled when admin APIs are disabled. Hence empty. false, // Disable admin APIs. + false, // Disable search API. + 0, // Default search max-limit. promslog.NewNopLogger(), func(context.Context) RulesRetriever { return &DummyRulesRetriever{} }, 0, 0, 0, // Remote read samples and concurrency limit. diff --git a/web/api/v1/helpers_test.go b/web/api/v1/helpers_test.go index 873a80c238..af00ea14f6 100644 --- a/web/api/v1/helpers_test.go +++ b/web/api/v1/helpers_test.go @@ -52,6 +52,8 @@ func newTestAPI(t *testing.T, cfg testhelpers.APIConfig) *testhelpers.APIWrapper adaptTSDBAdminStats(params.TSDBAdmin), params.DBDir, false, // enableAdmin + false, // enableSearch + 0, // maxSearchLimit params.Logger, func(ctx context.Context) RulesRetriever { return adaptRulesRetriever(params.RulesRetriever(ctx)) diff --git a/web/api/v1/openapi.go b/web/api/v1/openapi.go index 839af8108e..841d21c871 100644 --- a/web/api/v1/openapi.go +++ b/web/api/v1/openapi.go @@ -272,6 +272,9 @@ func (b *OpenAPIBuilder) getAllPathDefinitions() *orderedmap.Map[string, *v3.Pat // Label endpoints. paths.Set("/labels", b.labelsPath()) paths.Set("/label/{name}/values", b.labelValuesPath()) + paths.Set("/search/metric_names", b.searchMetricNamesPath()) + paths.Set("/search/label_names", b.searchLabelNamesPath()) + paths.Set("/search/label_values", b.searchLabelValuesPath()) // Series endpoints. paths.Set("/series", b.seriesPath()) diff --git a/web/api/v1/openapi_examples.go b/web/api/v1/openapi_examples.go index 25637adcde..71a7faff0a 100644 --- a/web/api/v1/openapi_examples.go +++ b/web/api/v1/openapi_examples.go @@ -163,6 +163,58 @@ func labelsPostExamples() *orderedmap.Map[string, *base.Example] { return examples } +// searchMetricNamesPostExamples returns examples for POST /search/metric_names endpoint. +func searchMetricNamesPostExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("metricAutocomplete", &base.Example{ + Summary: "Search metric names for autocomplete", + Value: createYAMLNode(map[string]any{ + "search[]": []string{"http_req"}, + "include_metadata": true, + "sort_by": "score", + "limit": 20, + }), + }) + + return examples +} + +// searchLabelNamesPostExamples returns examples for POST /search/label_names endpoint. +func searchLabelNamesPostExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("labelsForMetric", &base.Example{ + Summary: "Search label names for a metric", + Value: createYAMLNode(map[string]any{ + "match[]": []string{"{__name__=\"http_requests_total\"}"}, + "search[]": []string{"sta"}, + "sort_by": "score", + "limit": 20, + }), + }) + + return examples +} + +// searchLabelValuesPostExamples returns examples for POST /search/label_values endpoint. +func searchLabelValuesPostExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("valuesForLabel", &base.Example{ + Summary: "Search values for a label", + Value: createYAMLNode(map[string]any{ + "label": "instance", + "match[]": []string{"up"}, + "search[]": []string{"909"}, + "sort_by": "score", + "limit": 10, + }), + }) + + return examples +} + // seriesPostExamples returns examples for POST /series endpoint. func seriesPostExamples() *orderedmap.Map[string, *base.Example] { examples := orderedmap.New[string, *base.Example]() @@ -1031,6 +1083,42 @@ func featuresResponseExamples() *orderedmap.Map[string, *base.Example] { return examples } +// searchMetricNamesResponseExamples returns examples for /search/metric_names response. +func searchMetricNamesResponseExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("metricNamesStream", &base.Example{ + Summary: "NDJSON stream of metric names", + Value: createYAMLNode("{\"results\":[{\"name\":\"http_requests_total\",\"type\":\"counter\",\"help\":\"Total HTTP requests.\"}]}\n{\"status\":\"success\",\"has_more\":false}\n"), + }) + + return examples +} + +// searchLabelNamesResponseExamples returns examples for /search/label_names response. +func searchLabelNamesResponseExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("labelNamesStream", &base.Example{ + Summary: "NDJSON stream of label names", + Value: createYAMLNode("{\"results\":[{\"name\":\"instance\"},{\"name\":\"job\"}]}\n{\"status\":\"success\",\"has_more\":false}\n"), + }) + + return examples +} + +// searchLabelValuesResponseExamples returns examples for /search/label_values response. +func searchLabelValuesResponseExamples() *orderedmap.Map[string, *base.Example] { + examples := orderedmap.New[string, *base.Example]() + + examples.Set("labelValuesStream", &base.Example{ + Summary: "NDJSON stream of label values", + Value: createYAMLNode("{\"results\":[{\"value\":\"localhost:9090\"},{\"value\":\"localhost:9091\"}]}\n{\"status\":\"success\",\"has_more\":true}\n"), + }) + + return examples +} + // errorResponseExamples returns examples for error responses. func errorResponseExamples() *orderedmap.Map[string, *base.Example] { examples := orderedmap.New[string, *base.Example]() diff --git a/web/api/v1/openapi_helpers.go b/web/api/v1/openapi_helpers.go index 666310bf0c..dbaef1806a 100644 --- a/web/api/v1/openapi_helpers.go +++ b/web/api/v1/openapi_helpers.go @@ -73,6 +73,10 @@ func integerSchema() *base.SchemaProxy { }) } +func booleanSchema() *base.SchemaProxy { + return schemaFromType("boolean") +} + func stringSchemaWithDescription(description string) *base.SchemaProxy { return base.CreateSchemaProxy(&base.Schema{ Type: []string{"string"}, @@ -105,6 +109,13 @@ func integerSchemaWithDescriptionAndExample(description string, example any) *ba }) } +func booleanSchemaWithDescription(description string) *base.SchemaProxy { + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"boolean"}, + Description: description, + }) +} + func stringArraySchemaWithDescription(description string) *base.SchemaProxy { return base.CreateSchemaProxy(&base.Schema{ Type: []string{"array"}, @@ -194,6 +205,36 @@ func stringSchemaWithConstValue(value string) *base.SchemaProxy { }) } +// enumStringSchema creates a string schema constrained to the given enum values. +// The first value is used as the example. Use this for query parameters where +// the description lives on the parameter object. +func enumStringSchema(values ...string) *base.SchemaProxy { + nodes := make([]*yaml.Node, len(values)) + for i, v := range values { + nodes[i] = &yaml.Node{Kind: yaml.ScalarNode, Value: v} + } + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Enum: nodes, + Example: createYAMLNode(values[0]), + }) +} + +// enumStringSchemaWithDescription creates a string schema constrained to the given enum values, +// with a description. The first value is used as the example. Use this for POST body properties. +func enumStringSchemaWithDescription(description string, values ...string) *base.SchemaProxy { + nodes := make([]*yaml.Node, len(values)) + for i, v := range values { + nodes[i] = &yaml.Node{Kind: yaml.ScalarNode, Value: v} + } + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"string"}, + Enum: nodes, + Description: description, + Example: createYAMLNode(values[0]), + }) +} + func dateTimeSchemaWithDescription(description string) *base.SchemaProxy { return base.CreateSchemaProxy(&base.Schema{ Type: []string{"string"}, @@ -283,6 +324,30 @@ func jsonResponseWithExamples(schemaRef string, examples *orderedmap.Map[string, } } +// textResponseWithExamples creates a text response with examples. +func textResponseWithExamples(contentType string, schema *base.SchemaProxy, examples *orderedmap.Map[string, *base.Example], description string) *v3.Response { + content := orderedmap.New[string, *v3.MediaType]() + mediaType := &v3.MediaType{ + Schema: schema, + } + if examples != nil { + mediaType.Examples = examples + } + content.Set(contentType, mediaType) + return &v3.Response{ + Description: description, + Content: content, + } +} + +// ndjsonResponsesWithErrorExamples creates responses for NDJSON streaming endpoints. +func ndjsonResponsesWithErrorExamples(successExamples, errorExamples *orderedmap.Map[string, *base.Example], successDescription, errorDescription string) *v3.Responses { + codes := orderedmap.New[string, *v3.Response]() + codes.Set("200", textResponseWithExamples("application/x-ndjson", stringSchemaWithDescription("NDJSON response stream."), successExamples, successDescription)) + codes.Set("default", jsonResponseWithExamples("Error", errorExamples, errorDescription)) + return &v3.Responses{Codes: codes} +} + // responsesWithErrorExamples creates responses with both success and error examples. func responsesWithErrorExamples(okSchemaRef string, successExamples, errorExamples *orderedmap.Map[string, *base.Example], successDescription, errorDescription string) *v3.Responses { codes := orderedmap.New[string, *v3.Response]() diff --git a/web/api/v1/openapi_paths.go b/web/api/v1/openapi_paths.go index 9739a7e9f9..50e9e35db7 100644 --- a/web/api/v1/openapi_paths.go +++ b/web/api/v1/openapi_paths.go @@ -199,6 +199,125 @@ func (*OpenAPIBuilder) labelValuesPath() *v3.PathItem { } } +// commonSearchParams returns the query parameters shared by all three search endpoints. +func commonSearchParams() []*v3.Parameter { + return []*v3.Parameter{ + queryParamWithExample("fuzz_threshold", "Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold.", false, integerSchema(), []example{{"example", 80}}), + queryParamWithExample("fuzz_alg", "Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.", false, enumStringSchema(FuzzAlgorithms()...), nil), + queryParamWithExample("case_sensitive", "Whether matching is case-sensitive.", false, booleanSchema(), []example{{"example", true}}), + queryParamWithExample("sort_by", "Sort mode. Supported values are alpha and score.", false, enumStringSchema("alpha", "score"), nil), + queryParamWithExample("sort_dir", "Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.", false, enumStringSchema("asc", "dsc"), nil), + queryParamWithExample("include_score", "Include the relevance score in each result.", false, booleanSchema(), []example{{"example", true}}), + } +} + +func (*OpenAPIBuilder) searchMetricNamesPath() *v3.PathItem { + params := append([]*v3.Parameter{ + queryParamWithExample("match[]", "Series selector argument used to scope metric discovery.", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"{job=\"prometheus\"}"}}}), + queryParamWithExample("search[]", "One or more search terms matched against metric names (OR logic).", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"http_req"}}}), + }, commonSearchParams()...) + params = append(params, + queryParamWithExample("include_metadata", "Include metric metadata in each result.", false, booleanSchema(), []example{{"example", true}}), + queryParamWithExample("start", "Start timestamp for metric name search.", false, timestampSchema(), timestampExamples(exampleTime.Add(-1*time.Hour))), + queryParamWithExample("end", "End timestamp for metric name search.", false, timestampSchema(), timestampExamples(exampleTime)), + queryParamWithExample("limit", "Maximum number of metric names to return.", false, integerSchema(), []example{{"example", 20}}), + queryParamWithExample("batch_size", "Preferred number of results per NDJSON batch.", false, integerSchema(), []example{{"example", 20}}), + ) + return &v3.PathItem{ + Get: &v3.Operation{ + OperationId: "search-metric-names", + Summary: "Search metric names", + Tags: []string{"metadata"}, + Parameters: params, + Responses: ndjsonResponsesWithErrorExamples(searchMetricNamesResponseExamples(), errorResponseExamples(), "Metric names streamed successfully.", "Error searching metric names."), + }, + Post: &v3.Operation{ + OperationId: "search-metric-names-post", + Summary: "Search metric names", + Tags: []string{"metadata"}, + RequestBody: formRequestBodyWithExamples("SearchMetricNamesPostInputBody", searchMetricNamesPostExamples(), "Submit a metric name search. This endpoint accepts the same parameters as the GET version."), + Responses: ndjsonResponsesWithErrorExamples(searchMetricNamesResponseExamples(), errorResponseExamples(), "Metric names streamed successfully via POST.", "Error searching metric names via POST."), + }, + } +} + +func (*OpenAPIBuilder) searchLabelNamesPath() *v3.PathItem { + params := append([]*v3.Parameter{ + queryParamWithExample("match[]", "Series selector argument used to scope label discovery.", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"{__name__=\"up\"}"}}}), + queryParamWithExample("search[]", "One or more search terms matched against label names (OR logic).", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"inst"}}}), + }, commonSearchParams()...) + params = append(params, + queryParamWithExample("start", "Start timestamp for label name search.", false, timestampSchema(), timestampExamples(exampleTime.Add(-1*time.Hour))), + queryParamWithExample("end", "End timestamp for label name search.", false, timestampSchema(), timestampExamples(exampleTime)), + queryParamWithExample("limit", "Maximum number of label names to return.", false, integerSchema(), []example{{"example", 20}}), + queryParamWithExample("batch_size", "Preferred number of results per NDJSON batch.", false, integerSchema(), []example{{"example", 20}}), + ) + return &v3.PathItem{ + Get: &v3.Operation{ + OperationId: "search-label-names", + Summary: "Search label names", + Tags: []string{"labels"}, + Parameters: params, + Responses: ndjsonResponsesWithErrorExamples(searchLabelNamesResponseExamples(), errorResponseExamples(), "Label names streamed successfully.", "Error searching label names."), + }, + Post: &v3.Operation{ + OperationId: "search-label-names-post", + Summary: "Search label names", + Tags: []string{"labels"}, + RequestBody: formRequestBodyWithExamples("SearchLabelNamesPostInputBody", searchLabelNamesPostExamples(), "Submit a label name search. This endpoint accepts the same parameters as the GET version."), + Responses: ndjsonResponsesWithErrorExamples(searchLabelNamesResponseExamples(), errorResponseExamples(), "Label names streamed successfully via POST.", "Error searching label names via POST."), + }, + } +} + +func (*OpenAPIBuilder) searchLabelValuesPath() *v3.PathItem { + params := append([]*v3.Parameter{ + queryParamWithExample("label", "Label name whose values should be searched.", true, stringSchema(), []example{{"example", "instance"}}), + queryParamWithExample("match[]", "Series selector argument used to scope label value discovery.", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"up"}}}), + queryParamWithExample("search[]", "One or more search terms matched against label values (OR logic).", false, base.CreateSchemaProxy(&base.Schema{ + Type: []string{"array"}, + Items: &base.DynamicValue[*base.SchemaProxy, bool]{A: stringSchema()}, + }), []example{{"example", []string{"909"}}}), + }, commonSearchParams()...) + params = append(params, + queryParamWithExample("start", "Start timestamp for label value search.", false, timestampSchema(), timestampExamples(exampleTime.Add(-1*time.Hour))), + queryParamWithExample("end", "End timestamp for label value search.", false, timestampSchema(), timestampExamples(exampleTime)), + queryParamWithExample("limit", "Maximum number of label values to return.", false, integerSchema(), []example{{"example", 10}}), + queryParamWithExample("batch_size", "Preferred number of results per NDJSON batch.", false, integerSchema(), []example{{"example", 10}}), + ) + return &v3.PathItem{ + Get: &v3.Operation{ + OperationId: "search-label-values", + Summary: "Search label values", + Tags: []string{"labels"}, + Parameters: params, + Responses: ndjsonResponsesWithErrorExamples(searchLabelValuesResponseExamples(), errorResponseExamples(), "Label values streamed successfully.", "Error searching label values."), + }, + Post: &v3.Operation{ + OperationId: "search-label-values-post", + Summary: "Search label values", + Tags: []string{"labels"}, + RequestBody: formRequestBodyWithExamples("SearchLabelValuesPostInputBody", searchLabelValuesPostExamples(), "Submit a label value search. This endpoint accepts the same parameters as the GET version."), + Responses: ndjsonResponsesWithErrorExamples(searchLabelValuesResponseExamples(), errorResponseExamples(), "Label values streamed successfully via POST.", "Error searching label values via POST."), + }, + } +} + func (*OpenAPIBuilder) seriesPath() *v3.PathItem { params := []*v3.Parameter{ queryParamWithExample("start", "Start timestamp for series query.", false, timestampSchema(), timestampExamples(exampleTime.Add(-1*time.Hour))), diff --git a/web/api/v1/openapi_schemas.go b/web/api/v1/openapi_schemas.go index d768f4208d..e9c82307d3 100644 --- a/web/api/v1/openapi_schemas.go +++ b/web/api/v1/openapi_schemas.go @@ -54,6 +54,9 @@ func (b *OpenAPIBuilder) buildComponents() *v3.Components { schemas.Set("LabelsOutputBody", b.stringArrayResponseBodySchema()) schemas.Set("LabelsPostInputBody", b.labelsPostInputBodySchema()) schemas.Set("LabelValuesOutputBody", b.stringArrayResponseBodySchema()) + schemas.Set("SearchMetricNamesPostInputBody", b.searchMetricNamesPostInputBodySchema()) + schemas.Set("SearchLabelNamesPostInputBody", b.searchLabelNamesPostInputBodySchema()) + schemas.Set("SearchLabelValuesPostInputBody", b.searchLabelValuesPostInputBodySchema()) // Series schemas. schemas.Set("SeriesOutputBody", b.labelsArrayResponseBodySchema()) @@ -735,6 +738,82 @@ func (*OpenAPIBuilder) seriesPostInputBodySchema() *base.SchemaProxy { }) } +// schemaProp is a name/schema pair used to build ordered property maps. +type schemaProp struct { + name string + schema *base.SchemaProxy +} + +// propsMap converts a slice of schemaProp into an ordered map, preserving order. +func propsMap(pairs []schemaProp) *orderedmap.Map[string, *base.SchemaProxy] { + m := orderedmap.New[string, *base.SchemaProxy]() + for _, p := range pairs { + m.Set(p.name, p.schema) + } + return m +} + +// commonSearchPostProps returns the properties shared by all three search POST bodies. +func commonSearchPostProps() []schemaProp { + return []schemaProp{ + {"fuzz_threshold", integerSchemaWithDescriptionAndExample("Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.", 80)}, + {"fuzz_alg", enumStringSchemaWithDescription("Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.", FuzzAlgorithms()...)}, + {"case_sensitive", booleanSchemaWithDescription("Form field: Whether matching is case-sensitive.")}, + {"sort_by", enumStringSchemaWithDescription("Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.", "alpha", "score")}, + {"sort_dir", enumStringSchemaWithDescription("Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.", "asc", "dsc")}, + {"include_score", booleanSchemaWithDescription("Form field: Include the relevance score in each result record.")}, + {"start", stringSchemaWithDescriptionAndExample("Form field: The start time of the query.", "2026-01-02T12:37:00.000Z")}, + {"end", stringSchemaWithDescriptionAndExample("Form field: The end time of the query.", "2026-01-02T13:37:00.000Z")}, + {"limit", integerSchemaWithDescriptionAndExample("Form field: The maximum number of results to return.", 20)}, + {"batch_size", integerSchemaWithDescriptionAndExample("Form field: Preferred number of results per NDJSON batch.", 20)}, + } +} + +func (*OpenAPIBuilder) searchMetricNamesPostInputBodySchema() *base.SchemaProxy { + props := append([]schemaProp{ + {"match[]", stringArraySchemaWithDescriptionAndExample("Form field: Series selector argument used to scope metric discovery.", []string{"{job=\"prometheus\"}"})}, + {"search[]", stringArraySchemaWithDescriptionAndExample("Form field: One or more search terms matched against metric names (OR logic).", []string{"http_req"})}, + }, commonSearchPostProps()...) + props = append(props, schemaProp{"include_metadata", booleanSchemaWithDescription("Form field: Include metric metadata in each result.")}) + + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "POST request body for metric name search.", + AdditionalProperties: &base.DynamicValue[*base.SchemaProxy, bool]{N: 1, B: false}, + Properties: propsMap(props), + }) +} + +func (*OpenAPIBuilder) searchLabelNamesPostInputBodySchema() *base.SchemaProxy { + props := append([]schemaProp{ + {"match[]", stringArraySchemaWithDescriptionAndExample("Form field: Series selector argument used to scope label discovery.", []string{"{__name__=\"up\"}"})}, + {"search[]", stringArraySchemaWithDescriptionAndExample("Form field: One or more search terms matched against label names (OR logic).", []string{"inst"})}, + }, commonSearchPostProps()...) + + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "POST request body for label name search.", + AdditionalProperties: &base.DynamicValue[*base.SchemaProxy, bool]{N: 1, B: false}, + Properties: propsMap(props), + }) +} + +func (*OpenAPIBuilder) searchLabelValuesPostInputBodySchema() *base.SchemaProxy { + props := append([]schemaProp{ + {"label", stringSchemaWithDescriptionAndExample("Form field: Label name whose values should be searched.", "instance")}, + {"match[]", stringArraySchemaWithDescriptionAndExample("Form field: Series selector argument used to scope label value discovery.", []string{"up"})}, + {"search[]", stringArraySchemaWithDescriptionAndExample("Form field: One or more search terms matched against label values (OR logic).", []string{"909"})}, + }, commonSearchPostProps()...) + + return base.CreateSchemaProxy(&base.Schema{ + Type: []string{"object"}, + Description: "POST request body for label value search.", + AdditionalProperties: &base.DynamicValue[*base.SchemaProxy, bool]{N: 1, B: false}, + Required: []string{"label"}, + Properties: propsMap(props), + }) +} + func (*OpenAPIBuilder) metadataSchema() *base.SchemaProxy { props := orderedmap.New[string, *base.SchemaProxy]() props.Set("type", stringSchemaWithDescription("Metric type (counter, gauge, histogram, summary, or untyped).")) diff --git a/web/api/v1/search.go b/web/api/v1/search.go new file mode 100644 index 0000000000..40aa9ce00d --- /dev/null +++ b/web/api/v1/search.go @@ -0,0 +1,842 @@ +// 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 + +// Search API stream contract: +// +// - Successful responses use Content-Type "application/x-ndjson" and consist +// of one JSON object per line. +// - Zero or more searchBatch lines are emitted, each carrying a "results" +// array and an optional "warnings" array. The first batch always emits +// even when "results" is empty so clients can observe warnings reliably. +// - The stream then terminates with EITHER a searchTrailer line (status +// "success", optional "warnings" delta, "has_more" indicator) OR a +// searchErrorResponse line (status "error", "errorType", "error") if the +// storage backend errored mid-stream after the first batch was sent. +// - Errors that occur before the first batch is written are reported as the +// usual non-streaming JSON error object with a 4xx/5xx status code. +// - Clients MUST tolerate an abrupt EOF without a trailer (e.g. transport +// failures or server shutdown) and MUST ignore unknown fields in the +// trailer for forward compatibility. +// +// Pagination scope: this version of the API has no cursor mechanism. The +// "has_more" flag in the trailer is informational only; clients that need +// more results should re-issue the request with a higher "limit" +// (subject to --web.search.max-limit) or narrow the "match[]" series selectors. +// A future version may introduce a cursor field in the trailer. + +import ( + "context" + "errors" + "fmt" + "math" + "net/http" + "slices" + "strconv" + "strings" + "time" + + jsoniter "github.com/json-iterator/go" + "go.uber.org/atomic" + + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/model/timestamp" + "github.com/prometheus/prometheus/scrape" + "github.com/prometheus/prometheus/storage" + "github.com/prometheus/prometheus/util/httputil" +) + +// defaultFuzzAlg is the algorithm assumed when fuzz_alg is not specified. +const defaultFuzzAlg = "subsequence" + +// maxSearchTermsPerRequest caps the number of search[] query parameters +// accepted in one request. Per-value filter cost grows with the number of +// terms (each term adds at least one substring filter, optionally a fuzzy +// filter), so an unbounded count is a DoS surface. 32 is comfortably above +// realistic autocomplete usage (typically 1–3 terms) and tight enough that +// worst-case per-value evaluation stays bounded. +const maxSearchTermsPerRequest = 32 + +// fuzzAlgorithms is the canonical list of supported fuzzy matching algorithms, +// used by validation, feature registration, and API documentation. It is kept +// unexported so it cannot be mutated by external packages; use FuzzAlgorithms() +// to obtain a defensive copy. +var fuzzAlgorithms = []string{defaultFuzzAlg, "jarowinkler"} + +// FuzzAlgorithms returns the canonical list of supported fuzzy matching +// algorithms. The returned slice is a copy and may be modified safely. +func FuzzAlgorithms() []string { + return slices.Clone(fuzzAlgorithms) +} + +// searchParams holds the common parsed parameters for all search endpoints. +type searchParams struct { + matcherSets [][]*labels.Matcher + searches []string + fuzzThreshold int // 0-100, default 0 (lowest fuzzy threshold). + fuzzAlg string // "subsequence" (default) or "jarowinkler". + caseSensitive bool // Default true. + sortBy string + sortDir string // "asc" (default) or "dsc". + includeScore bool // Include relevance score in each result record. + start, end time.Time + limit int // Default 100. + batchSize int // Default 100. +} + +// searchMetricNameResult is a single result record for the metric_names endpoint. +type searchMetricNameResult struct { + Name string `json:"name"` + Score *float64 `json:"score,omitempty"` + Type string `json:"type,omitempty"` + Help string `json:"help,omitempty"` + Unit string `json:"unit,omitempty"` +} + +// searchLabelNameResult is a single result record for the label_names endpoint. +type searchLabelNameResult struct { + Name string `json:"name"` + Score *float64 `json:"score,omitempty"` +} + +// searchLabelValueResult is a single result record for the label_values endpoint. +type searchLabelValueResult struct { + Value string `json:"value"` + Score *float64 `json:"score,omitempty"` +} + +// searchBatch is a single NDJSON batch line containing results and optional warnings. +type searchBatch[T any] struct { + Results []T `json:"results"` + Warnings []string `json:"warnings,omitempty"` +} + +// searchTrailer is the final NDJSON line indicating completion status. +type searchTrailer struct { + Status string `json:"status"` + HasMore bool `json:"has_more"` + Warnings []string `json:"warnings,omitempty"` +} + +// searchErrorResponse is an NDJSON error line. +type searchErrorResponse struct { + Status string `json:"status"` + ErrorType string `json:"errorType"` + Error string `json:"error"` +} + +// ndjsonWriter writes newline-delimited JSON lines with flushing. +type ndjsonWriter struct { + w http.ResponseWriter + flusher http.Flusher + json jsoniter.API +} + +// newNDJSONWriter creates a new NDJSON writer, setting the appropriate content type. +func newNDJSONWriter(w http.ResponseWriter) (*ndjsonWriter, error) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, errors.New("response writer does not support flushing") + } + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.WriteHeader(http.StatusOK) + return &ndjsonWriter{ + w: w, + flusher: flusher, + json: jsoniter.ConfigCompatibleWithStandardLibrary, + }, nil +} + +// writeLine marshals v as JSON, writes it as a single line, and flushes. +func (nw *ndjsonWriter) writeLine(v any) error { + b, err := nw.json.Marshal(v) + if err != nil { + return err + } + b = append(b, '\n') + _, err = nw.w.Write(b) + if err != nil { + return err + } + nw.flusher.Flush() + return nil +} + +// parseSearchParams parses the common query parameters for search endpoints. +func (api *API) parseSearchParams(r *http.Request) (searchParams, *apiError) { + var sp searchParams + + now := api.now() + start, err := parseTimeParam(r, "start", now.Add(-time.Hour)) + if err != nil { + return sp, &apiError{errorBadData, err} + } + sp.start = start + + end, err := parseTimeParam(r, "end", now) + if err != nil { + return sp, &apiError{errorBadData, err} + } + sp.end = end + // end == start is permitted: it represents a zero-duration "snapshot at + // this instant" search. Only strictly inverted ranges are rejected, so + // a client that accidentally sets end < start gets an immediate error + // rather than empty (and possibly misleading) results. + if sp.end.Before(sp.start) { + return sp, &apiError{errorBadData, errors.New("end timestamp must not be before start timestamp")} + } + + if matchers := r.Form["match[]"]; len(matchers) > 0 { + matcherSets, err := api.parseMatchersParam(matchers) + if err != nil { + return sp, &apiError{errorBadData, err} + } + sp.matcherSets = matcherSets + } + + sp.searches = r.Form["search[]"] + if len(sp.searches) > maxSearchTermsPerRequest { + return sp, &apiError{errorBadData, fmt.Errorf( + "too many search[] terms: got %d, maximum is %d", + len(sp.searches), maxSearchTermsPerRequest, + )} + } + + sp.fuzzThreshold = 0 + if v := r.FormValue("fuzz_threshold"); v != "" { + ft, err := strconv.Atoi(v) + if err != nil || ft < 0 || ft > 100 { + return sp, &apiError{errorBadData, fmt.Errorf("invalid fuzz_threshold %q: must be 0-100", v)} + } + sp.fuzzThreshold = ft + } + + // Validate fuzz_alg if provided; fuzzAlgorithms lists all supported values. + sp.fuzzAlg = defaultFuzzAlg + if v := r.FormValue("fuzz_alg"); v != "" { + if !slices.Contains(fuzzAlgorithms, v) { + return sp, &apiError{errorBadData, fmt.Errorf("unsupported fuzz_alg %q: must be one of %v", v, fuzzAlgorithms)} + } + sp.fuzzAlg = v + } + + caseSensitive, apiErr := parseSearchBoolParam(r, "case_sensitive", true) + if apiErr != nil { + return sp, apiErr + } + sp.caseSensitive = caseSensitive + + includeScore, apiErr := parseSearchBoolParam(r, "include_score", false) + if apiErr != nil { + return sp, apiErr + } + sp.includeScore = includeScore + + sp.sortBy = r.FormValue("sort_by") + sp.sortDir = r.FormValue("sort_dir") + if sp.sortDir != "" && sp.sortBy == "" { + return sp, &apiError{errorBadData, errors.New("sort_dir is only valid when sort_by is set")} + } + if sp.sortDir != "" && sp.sortBy == "score" { + return sp, &apiError{errorBadData, errors.New("sort_dir is not supported for sort_by=score")} + } + if sp.sortDir == "" { + sp.sortDir = "asc" + } + if sp.sortDir != "asc" && sp.sortDir != "dsc" { + return sp, &apiError{errorBadData, fmt.Errorf("invalid sort_dir %q: must be \"asc\" or \"dsc\"", sp.sortDir)} + } + if sp.sortBy == "score" && len(sp.searches) == 0 { + return sp, &apiError{errorBadData, errors.New("sort_by=score requires search[] to be set")} + } + + // Default limit is shrunk to maxSearchLimit when the operator configured + // a smaller cap, so a request that omits "limit" still serves up to the + // configured maximum rather than failing the cap check unconditionally. + sp.limit = 100 + if api.maxSearchLimit > 0 && sp.limit > api.maxSearchLimit { + sp.limit = api.maxSearchLimit + } + if v := r.FormValue("limit"); v != "" { + l, err := strconv.Atoi(v) + if err != nil || l < 0 { + return sp, &apiError{errorBadData, fmt.Errorf("invalid limit %q: must be non-negative integer", v)} + } + if l > 0 { + if api.maxSearchLimit > 0 && l > api.maxSearchLimit { + return sp, &apiError{errorBadData, fmt.Errorf("limit %d exceeds the configured maximum (%d, see --web.search.max-limit)", l, api.maxSearchLimit)} + } + sp.limit = l + } + } + + sp.batchSize = 100 + if v := r.FormValue("batch_size"); v != "" { + bs, err := strconv.Atoi(v) + if err != nil || bs < 0 { + return sp, &apiError{errorBadData, fmt.Errorf("invalid batch_size %q: must be non-negative integer", v)} + } + if bs > 0 { + sp.batchSize = bs + } + // batch_size=0 means server-determined; keep default. + } + + return sp, nil +} + +// searchHintsLimit returns the storage-level Limit for a request: one above +// spLimit so the streamer can detect has_more by probing past the cap. The +// saturation guard avoids the int overflow that would be possible when the +// operator has disabled the cap with --web.search.max-limit=0 and a client +// supplies a near-MaxInt limit. +func searchHintsLimit(spLimit int) int { + if spLimit >= math.MaxInt { + return math.MaxInt + } + return spLimit + 1 +} + +func parseSearchBoolParam(r *http.Request, name string, defaultValue bool) (bool, *apiError) { + v := r.FormValue(name) + if v == "" { + return defaultValue, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, &apiError{errorBadData, fmt.Errorf("invalid %s %q: must be boolean", name, v)} + } + return b, nil +} + +// metricMetadataCacheTTL is the lifetime of a cached metric-metadata map. +// Autocomplete UIs hit /api/v1/search/metric_names with include_metadata=true +// on every keystroke; without a cache each request locks the scrape manager +// and walks every active target's metadata. The TTL is short so that operators +// who push new scrape targets see them in the search results within seconds. +const metricMetadataCacheTTL = 5 * time.Second + +// metadataCacheEntry is the immutable payload stored in searchMetadataCache. +// Both fields are set together on each cache update so a reader observing a +// non-nil entry can read built and data without further synchronisation. +type metadataCacheEntry struct { + built time.Time + data map[string]scrape.MetricMetadata +} + +// searchMetadataCache caches the metric metadata map produced by +// buildMetricMetadataMap for a short TTL. It is shared across all in-flight +// /api/v1/search/metric_names requests on one API instance. Reads are +// lock-free via atomic.Pointer; concurrent writers race and the last Store +// wins, which is acceptable because the underlying scrape-manager snapshot is +// idempotent. +type searchMetadataCache struct { + entry atomic.Pointer[metadataCacheEntry] +} + +// buildMetricMetadataMap snapshots metric metadata across all active targets +// into a single map keyed by metric family name. It is intended to be called +// once per request when include_metadata=true so that per-result metadata +// lookups are O(1) and we acquire the scrape manager lock only once instead +// of once per emitted result. +// +// Results are cached on api.metaCache for metricMetadataCacheTTL so that a +// burst of autocomplete requests does not re-lock the scrape manager on every +// keystroke. Concurrent cache misses may rebuild redundantly — the alternative +// (sync/singleflight) is more complexity than the experimental endpoint +// warrants. Partial maps from a cancelled ctx are not stored in the cache. +// +// Callers must treat the returned map as read-only: on a cache hit the map +// is shared across concurrent requests, and any mutation would race with +// every other reader holding the same reference. +// +// Iteration order over active targets is non-deterministic; for a metric name +// that appears on multiple targets we keep the first metadata seen, matching +// the prior per-result fallthrough behaviour. +// +// The traversal aborts as soon as ctx is done so a request that the client +// has already abandoned (or one that has run past its deadline) does not +// keep accumulating per-target locks. Callers tolerate a partial map: a +// missing entry just means the result is emitted without metadata. +func (api *API) buildMetricMetadataMap(ctx context.Context) map[string]scrape.MetricMetadata { + if c := api.metaCache; c != nil { + if e := c.entry.Load(); e != nil && api.now().Sub(e.built) < metricMetadataCacheTTL { + return e.data + } + } + + tr := api.targetRetriever(ctx) + if tr == nil { + return nil + } + out := map[string]scrape.MetricMetadata{} + for _, targets := range tr.TargetsActive() { + if ctx.Err() != nil { + return out + } + for _, t := range targets { + if ctx.Err() != nil { + return out + } + for _, md := range t.ListMetadata() { + if ctx.Err() != nil { + return out + } + if _, exists := out[md.MetricFamily]; !exists { + out[md.MetricFamily] = md + } + } + } + } + + if ctx.Err() == nil && api.metaCache != nil { + api.metaCache.entry.Store(&metadataCacheEntry{built: api.now(), data: out}) + } + return out +} + +// sortOrdering maps sort_by and sort_dir parameters to a storage.Ordering. +func sortOrdering(sortBy, sortDir string) storage.Ordering { + switch sortBy { + case "score": + return storage.OrderByScoreDesc + case "alpha": + if sortDir == "dsc" { + return storage.OrderByValueDesc + } + } + return storage.OrderByValueAsc +} + +func searchAPIError(err error) *apiError { + result := setUnavailStatusOnTSDBNotReady(apiFuncResult{err: returnAPIError(err)}) + return result.err +} + +func (api *API) respondPreStreamSearchError(w http.ResponseWriter, err error) { + api.respondError(w, searchAPIError(err), nil) +} + +func writeStreamSearchError(nw *ndjsonWriter, err error) { + apiErr := searchAPIError(err) + _ = nw.writeLine(searchErrorResponse{Status: "error", ErrorType: apiErr.typ.str, Error: apiErr.err.Error()}) +} + +func writeStreamInternalError(nw *ndjsonWriter, err error) { + _ = nw.writeLine(searchErrorResponse{Status: "error", ErrorType: errorInternal.str, Error: err.Error()}) +} + +func searchWarnings(rs storage.SearchResultSet) []string { + var warnings []string + for _, w := range rs.Warnings() { + warnings = append(warnings, w.Error()) + } + return warnings +} + +// searchResultStreamer is generic because each endpoint streams a distinct result record type. +// The batching and has_more logic is shared. +type searchResultStreamer[T any] struct { + rs storage.SearchResultSet + limit int + batchSize int + emitted int + hasMore bool + toResult func(storage.SearchResult) T +} + +func (s *searchResultStreamer[T]) nextBatch() ([]T, error) { + if s.hasMore { + return nil, nil + } + batch := make([]T, 0, s.batchSize) + for len(batch) < s.batchSize { + if s.limit > 0 && s.emitted >= s.limit { + if s.rs.Next() { + s.hasMore = true + } + return batch, s.rs.Err() + } + if !s.rs.Next() { + return batch, s.rs.Err() + } + s.emitted++ + batch = append(batch, s.toResult(s.rs.At())) + } + return batch, nil +} + +func streamSearchResults[T any](ctx context.Context, api *API, w http.ResponseWriter, rs storage.SearchResultSet, sp searchParams, toResult func(storage.SearchResult) T) { + defer func() { _ = rs.Close() }() + + streamer := &searchResultStreamer[T]{ + rs: rs, + limit: sp.limit, + batchSize: sp.batchSize, + toResult: toResult, + } + + firstBatch, firstErr := streamer.nextBatch() + // A non-nil firstErr with zero results means the underlying iterator + // could not produce anything; respond with the standard JSON error so + // clients see a well-formed failure. When firstErr arrives alongside + // partial results (e.g. one matcher-set succeeded and another failed, + // fitting in the first batch), we open the stream so the partial data + // is not lost — the in-band error line below signals the failure. + if firstErr != nil && len(firstBatch) == 0 { + api.respondPreStreamSearchError(w, firstErr) + return + } + // Sort warnings so the order is deterministic on the wire and the + // trailer dedup against trailerWarnings below is order-independent. + // annotations.Annotations is map-backed. + firstWarnings := searchWarnings(rs) + slices.Sort(firstWarnings) + + nw, err := newNDJSONWriter(w) + if err != nil { + api.respondError(w, &apiError{errorInternal, err}, nil) + return + } + + // Always emit a first batch line so warnings are observable before any + // trailer or error line, even when there are no results. + if writeErr := nw.writeLine(searchBatch[T]{Results: firstBatch, Warnings: firstWarnings}); writeErr != nil { + writeStreamInternalError(nw, writeErr) + return + } + + if firstErr != nil { + writeStreamSearchError(nw, firstErr) + return + } + + if len(firstBatch) == 0 { + _ = nw.writeLine(searchTrailer{Status: "success", HasMore: streamer.hasMore}) + return + } + + for { + // Stop pulling from storage as soon as the client goes away. + // Without this check, an abandoned request keeps iterating the + // underlying SearchResultSet (which may itself be doing real I/O). + select { + case <-ctx.Done(): + return + default: + } + batch, err := streamer.nextBatch() + if len(batch) > 0 { + if writeErr := nw.writeLine(searchBatch[T]{Results: batch}); writeErr != nil { + writeStreamInternalError(nw, writeErr) + return + } + } + if err != nil { + writeStreamSearchError(nw, err) + return + } + if len(batch) == 0 { + break + } + } + + // Re-snapshot warnings after iteration: a Searcher may emit new warnings + // while the merge tree is drained (e.g. a secondary querier whose error + // becomes a warning at exhaustion). Dedup against the first batch so we + // don't echo warnings the client has already received. Sort to match + // firstWarnings' canonical order. + trailerWarnings := searchWarnings(rs) + slices.Sort(trailerWarnings) + if slices.Equal(trailerWarnings, firstWarnings) { + trailerWarnings = nil + } + _ = nw.writeLine(searchTrailer{Status: "success", HasMore: streamer.hasMore, Warnings: trailerWarnings}) +} + +// searchLabelValues retrieves label values using the Searcher interface. +// For multiple matcher sets the per-set results are merged via the storage +// helper, which handles deduplication, max-score collapse, and ordering. +// Each per-set sub-query is wrapped in a lazy SearchResultSet so the merge +// tree can short-circuit on limit without paying construction cost for +// branches it never pulls from. +func searchLabelValues(ctx context.Context, searcher storage.Searcher, name string, matcherSets [][]*labels.Matcher, hints *storage.SearchHints) storage.SearchResultSet { + if len(matcherSets) > 1 { + sets := make([]storage.SearchResultSet, 0, len(matcherSets)) + for _, matchers := range matcherSets { + sets = append(sets, storage.NewLazySearchResultSet(func() storage.SearchResultSet { + return searcher.SearchLabelValues(ctx, name, hints, matchers...) + })) + } + return storage.MergeSearchResultSets(sets, hints) + } + + var matchers []*labels.Matcher + if len(matcherSets) == 1 { + matchers = matcherSets[0] + } + return searcher.SearchLabelValues(ctx, name, hints, matchers...) +} + +// searchLabelNames retrieves label names using the Searcher interface. +// For multiple matcher sets the per-set results are merged via the storage +// helper, which handles deduplication, max-score collapse, and ordering. +// Each per-set sub-query is wrapped in a lazy SearchResultSet so the merge +// tree can short-circuit on limit without paying construction cost for +// branches it never pulls from. +func searchLabelNames(ctx context.Context, searcher storage.Searcher, matcherSets [][]*labels.Matcher, hints *storage.SearchHints) storage.SearchResultSet { + if len(matcherSets) > 1 { + sets := make([]storage.SearchResultSet, 0, len(matcherSets)) + for _, matchers := range matcherSets { + sets = append(sets, storage.NewLazySearchResultSet(func() storage.SearchResultSet { + return searcher.SearchLabelNames(ctx, hints, matchers...) + })) + } + return storage.MergeSearchResultSets(sets, hints) + } + + var matchers []*labels.Matcher + if len(matcherSets) == 1 { + matchers = matcherSets[0] + } + return searcher.SearchLabelNames(ctx, hints, matchers...) +} + +// buildSearchFilter builds a Filter for the given search terms and fuzzy settings. +// When multiple search terms are given, results matching any term are accepted (OR logic). +// Empty search terms are skipped. Returns nil when no usable search terms remain. +// For case-insensitive search, the query is lowercased here and the chain is wrapped +// with caseFoldingFilter so values are lowercased once at the top of the chain. +// When the chain contains an expensive matcher (subsequence, or Jaro-Winkler with a +// non-zero threshold) it is wrapped with memoizingFilter so values that reach the +// chain multiple times in one search (e.g. once per TSDB block) are scored once. +// Substring-only chains skip the memo: substring scoring is already O(L) and +// the cache lookup would only add overhead. +func buildSearchFilter(searches []string, fuzzThreshold int, fuzzAlg string, caseSensitive bool) storage.Filter { + terms := make([]string, 0, len(searches)) + for _, s := range searches { + if s == "" { + continue + } + if !caseSensitive { + s = strings.ToLower(s) + } + terms = append(terms, s) + } + if len(terms) == 0 { + return nil + } + threshold := float64(fuzzThreshold) / 100.0 + filters := make([]storage.Filter, 0, len(terms)) + for _, s := range terms { + var f storage.Filter + if fuzzAlg == "subsequence" { + f = NewSubsequenceFilter(s, threshold) + } else { + // Jaro-Winkler: substring OR Jaro-Winkler fuzzy. + substringFilter := NewSubstringFilter(s) + var fuzzyFilter *FuzzyFilter + if fuzzThreshold > 0 { + fuzzyFilter = NewFuzzyFilter(s, threshold) + } + f = &orFilter{ + substringFilter: substringFilter, + fuzzyFilter: fuzzyFilter, + } + } + filters = append(filters, f) + } + var combined storage.Filter + if len(filters) == 1 { + combined = filters[0] + } else { + combined = newOrSearchesFilter(filters...) + } + if !caseSensitive { + combined = newCaseFoldingFilter(combined) + } + if filterChainHasExpensiveScoring(fuzzThreshold, fuzzAlg) { + combined = newMemoizingFilter(combined) + } + return combined +} + +// filterChainHasExpensiveScoring reports whether the search filter chain built +// by buildSearchFilter will exercise a non-trivial scoring path that justifies +// memoization across blocks. +func filterChainHasExpensiveScoring(fuzzThreshold int, fuzzAlg string) bool { + if fuzzAlg == "subsequence" { + return true + } + // Jaro-Winkler is only constructed when fuzzThreshold > 0; below that the + // chain is substring-only and memoization is not worth its overhead. + return fuzzThreshold > 0 +} + +// searchRequest holds the common objects prepared by newSearchRequest for a search request. +type searchRequest struct { + sp searchParams + hints *storage.SearchHints + searcher storage.Searcher + q storage.Querier +} + +// newSearchRequest handles the setup shared by all search endpoints: CORS headers, +// feature-gate checks, form parsing, common parameter parsing, sort_by +// validation, querier acquisition, and search hint construction. On success a +// non-nil searchRequest is returned and the caller must defer req.q.Close(). On +// failure the error has already been written to w and nil is returned. +func (api *API) newSearchRequest(w http.ResponseWriter, r *http.Request, endpoint string) *searchRequest { + httputil.SetCORS(w, api.CORSOrigin, r) + + if !api.enableSearch { + api.respondError(w, &apiError{errorUnavailable, errors.New("search API disabled")}, nil) + return nil + } + + if api.isAgent { + api.respondError(w, &apiError{errorExec, errors.New("unavailable with Prometheus Agent")}, nil) + return nil + } + + if err := r.ParseForm(); err != nil { + api.respondError(w, &apiError{errorBadData, fmt.Errorf("error parsing form values: %w", err)}, nil) + return nil + } + + sp, apiErr := api.parseSearchParams(r) + if apiErr != nil { + api.respondError(w, apiErr, nil) + return nil + } + + if sp.sortBy != "" && sp.sortBy != "alpha" && sp.sortBy != "score" { + api.respondError(w, &apiError{errorBadData, fmt.Errorf("invalid sort_by %q for %s: must be \"alpha\" or \"score\"", sp.sortBy, endpoint)}, nil) + return nil + } + + q, err := api.Queryable.Querier(timestamp.FromTime(sp.start), timestamp.FromTime(sp.end)) + if err != nil { + api.respondPreStreamSearchError(w, err) + return nil + } + + searcher, ok := q.(storage.Searcher) + if !ok { + _ = q.Close() + api.respondError(w, &apiError{errorInternal, errors.New("search not supported by storage")}, nil) + return nil + } + + hints := &storage.SearchHints{ + Filter: buildSearchFilter(sp.searches, sp.fuzzThreshold, sp.fuzzAlg, sp.caseSensitive), + Limit: searchHintsLimit(sp.limit), // Fetch one extra to detect has_more (with saturation guard). + } + hints.OrderBy = sortOrdering(sp.sortBy, sp.sortDir) + + return &searchRequest{sp: sp, hints: hints, searcher: searcher, q: q} +} + +// searchMetricNames handles GET/POST /api/v1/search/metric_names. +func (api *API) searchMetricNames(w http.ResponseWriter, r *http.Request) { + req := api.newSearchRequest(w, r, "metric_names") + if req == nil { + return + } + defer req.q.Close() + + includeMetadata, apiErr := parseSearchBoolParam(r, "include_metadata", false) + if apiErr != nil { + api.respondError(w, apiErr, nil) + return + } + + ctx := r.Context() + + // metaMap is built lazily on the first metadata lookup so a search that + // returns zero results never pays the scrape-manager lock + map-build + // cost. The streamer drives toResult from a single goroutine, so the + // captured flag does not need a lock. + var ( + metaMap map[string]scrape.MetricMetadata + metaMapDone bool + ) + + searchResults := searchLabelValues(ctx, req.searcher, labels.MetricName, req.sp.matcherSets, req.hints) + streamSearchResults(ctx, api, w, searchResults, req.sp, func(sr storage.SearchResult) searchMetricNameResult { + result := searchMetricNameResult{Name: sr.Value} + if req.sp.includeScore { + score := sr.Score + result.Score = &score + } + if includeMetadata { + if !metaMapDone { + metaMap = api.buildMetricMetadataMap(ctx) + metaMapDone = true + } + if md, ok := metaMap[sr.Value]; ok { + result.Type = string(md.Type) + result.Help = md.Help + result.Unit = md.Unit + } + } + return result + }) +} + +// searchLabelNames handles GET/POST /api/v1/search/label_names. +func (api *API) searchLabelNames(w http.ResponseWriter, r *http.Request) { + req := api.newSearchRequest(w, r, "label_names") + if req == nil { + return + } + defer req.q.Close() + + ctx := r.Context() + searchResults := searchLabelNames(ctx, req.searcher, req.sp.matcherSets, req.hints) + streamSearchResults(ctx, api, w, searchResults, req.sp, func(sr storage.SearchResult) searchLabelNameResult { + result := searchLabelNameResult{Name: sr.Value} + if req.sp.includeScore { + score := sr.Score + result.Score = &score + } + return result + }) +} + +// searchLabelValues handles GET/POST /api/v1/search/label_values. +func (api *API) searchLabelValues(w http.ResponseWriter, r *http.Request) { + req := api.newSearchRequest(w, r, "label_values") + if req == nil { + return + } + defer req.q.Close() + + labelName := r.FormValue("label") + if labelName == "" { + api.respondError(w, &apiError{errorBadData, errors.New("missing required parameter \"label\"")}, nil) + return + } + + ctx := r.Context() + searchResults := searchLabelValues(ctx, req.searcher, labelName, req.sp.matcherSets, req.hints) + streamSearchResults(ctx, api, w, searchResults, req.sp, func(sr storage.SearchResult) searchLabelValueResult { + result := searchLabelValueResult{Value: sr.Value} + if req.sp.includeScore { + score := sr.Score + result.Score = &score + } + return result + }) +} diff --git a/web/api/v1/search_filters.go b/web/api/v1/search_filters.go new file mode 100644 index 0000000000..a881928973 --- /dev/null +++ b/web/api/v1/search_filters.go @@ -0,0 +1,324 @@ +// 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 ( + "strings" + "unicode/utf8" + + "github.com/prometheus/prometheus/storage" + "github.com/prometheus/prometheus/util/strutil" +) + +// Filters in this file expect the query passed to their constructor and the +// value passed to Accept to share the same case. To search case-insensitively, +// lowercase the query at construction time and wrap the resulting filter with +// caseFoldingFilter so incoming values are lowercased once for the whole chain. + +// SubstringFilter implements case-sensitive substring matching with a +// position-based score. A prefix match scores 1.0; substring matches score in +// the range [0.1, 1.0) where earlier match positions score higher. +type SubstringFilter struct { + query string +} + +// NewSubstringFilter creates a new SubstringFilter. The query is matched +// against incoming values in their literal case. +func NewSubstringFilter(query string) *SubstringFilter { + return &SubstringFilter{query: query} +} + +// Accept returns true if the value contains the substring query, with a score +// that decreases as the match position moves toward the end of the value. +// +// The position component is computed in characters (runes), not bytes, so a +// match at character offset 1 in a multi-byte string scores the same as a +// match at byte offset 1 in an ASCII string. An ASCII fast path keeps the +// common case at byte arithmetic. +func (f *SubstringFilter) Accept(value string) (bool, float64) { + if f.query == "" { + return true, 1.0 + } + idx := strings.Index(value, f.query) + if idx < 0 { + return false, 0.0 + } + if idx == 0 { + return true, 1.0 + } + var pos, maxPos int + // The fast path requires the entire value to be ASCII: if any byte + // after the match is multi-byte, len(value) overcounts characters and + // inflates the score. Checking the whole string is O(len(value)) but + // short-circuits on the first non-ASCII byte, so the typical metric + // name pays only a tight byte-loop. + if isASCII(f.query) && isASCII(value) { + pos = idx + maxPos = len(value) - len(f.query) + } else { + pos = utf8.RuneCountInString(value[:idx]) + maxPos = utf8.RuneCountInString(value) - utf8.RuneCountInString(f.query) + } + if maxPos <= 0 { + // Defensive: maxPos==0 would imply value and query have equal + // rune counts, which forces idx==0 and is handled above. Treat + // any unreachable case as a perfect match rather than dividing + // by zero. + return true, 1.0 + } + // Scale to [0.1, 1.0). Earlier positions score closer to 1.0. + score := 1.0 - 0.9*float64(pos)/float64(maxPos) + return true, score +} + +// isASCII reports whether s contains only ASCII bytes (< 0x80). +func isASCII(s string) bool { + for i := range len(s) { + if s[i] >= 0x80 { + return false + } + } + return true +} + +// FuzzyFilter implements Jaro-Winkler fuzzy matching against a query. +type FuzzyFilter struct { + query string + matcher *strutil.JaroWinklerMatcher + threshold float64 +} + +// NewFuzzyFilter creates a new FuzzyFilter. +// threshold should be in range [0.0, 1.0] where 1.0 requires perfect match. +func NewFuzzyFilter(query string, threshold float64) *FuzzyFilter { + return &FuzzyFilter{ + query: query, + matcher: strutil.NewJaroWinklerMatcher(query), + threshold: threshold, + } +} + +// Accept returns true if the value matches the fuzzy query above the threshold. +func (f *FuzzyFilter) Accept(value string) (bool, float64) { + score := f.matcher.Score(value) + return score >= f.threshold, score +} + +// SubsequenceFilter implements fuzzy matching using a sequential character +// matching algorithm. It requires all pattern characters to appear in the value +// in order (subsequence matching), and scores matches by rewarding consecutive +// character runs and penalizing gaps. +// The score is normalized to [0.0, 1.0] and compared against a threshold. +type SubsequenceFilter struct { + query string + matcher *strutil.SubsequenceMatcher + threshold float64 +} + +// NewSubsequenceFilter creates a new SubsequenceFilter. +// threshold should be in range [0.0, 1.0] where 0.0 accepts any subsequence match. +func NewSubsequenceFilter(query string, threshold float64) *SubsequenceFilter { + return &SubsequenceFilter{ + query: query, + matcher: strutil.NewSubsequenceMatcher(query), + threshold: threshold, + } +} + +// Accept returns true if the value matches the subsequence query above the threshold. +// Prefix matches always score 1.0 for consistency with SubstringFilter. +func (f *SubsequenceFilter) Accept(value string) (bool, float64) { + if strings.HasPrefix(value, f.query) { + return true, 1.0 + } + score := f.matcher.Score(value) + // score == 0 means no subsequence match; always reject regardless of threshold. + return score > 0 && score >= f.threshold, score +} + +// caseFoldingFilter wraps another Filter and lowercases the value once before +// delegating, so a chain of case-insensitive matchers does not each repeat the +// case fold. The wrapped filter must have been constructed with a lowercased +// query. +type caseFoldingFilter struct { + inner storage.Filter +} + +func newCaseFoldingFilter(inner storage.Filter) *caseFoldingFilter { + return &caseFoldingFilter{inner: inner} +} + +// Accept lowercases the value and delegates to the inner filter. +func (f *caseFoldingFilter) Accept(value string) (bool, float64) { + return f.inner.Accept(strings.ToLower(value)) +} + +// memoEntry stores a cached filter result. +type memoEntry struct { + accepted bool + score float64 +} + +// memoizingFilterMaxEntries and memoizingFilterMaxBytes together cap the +// memoization cache. Past either cap, lookups fall through to the inner +// filter without populating the cache further. The entry cap protects +// against tiny-value blowup (millions of short distinct values); the byte +// cap protects against long-value blowup (a label of URLs or user agents +// where 100k entries would otherwise hold tens of megabytes). Bytes are +// counted as the sum of cached key lengths — a coarse but cheap approximation +// of map memory. +const ( + memoizingFilterMaxEntries = 100_000 + memoizingFilterMaxBytes = 10 << 20 // 10 MiB. +) + +// memoizingFilter caches the (accepted, score) returned by the inner filter +// for each distinct value. It is intended to be used as the outermost wrapper +// in buildSearchFilter so that values reaching the chain multiple times in a +// single search (e.g. once per TSDB block during a multi-block lookup) are +// scored only once. +// +// memoizingFilter is not safe for concurrent use: the search path drives +// SearchResultSet iteration from a single goroutine (the merge driver pulls +// child sets sequentially and ApplySearchHints runs synchronously inside one +// Searcher). Adding a cache lock would charge every Accept on the hot path +// without buying anything, so the lock is omitted. +type memoizingFilter struct { + inner storage.Filter + cache map[string]memoEntry + bytes int // Sum of len(key) for currently cached entries. +} + +func newMemoizingFilter(inner storage.Filter) *memoizingFilter { + return &memoizingFilter{ + inner: inner, + cache: make(map[string]memoEntry), + } +} + +// Accept returns the cached result for value, computing and caching it on miss. +// Once the cache reaches either memoizingFilterMaxEntries or +// memoizingFilterMaxBytes, the inner filter is called directly without +// populating the cache further; this preserves correctness while bounding +// memory. +func (f *memoizingFilter) Accept(value string) (bool, float64) { + if e, ok := f.cache[value]; ok { + return e.accepted, e.score + } + accepted, score := f.inner.Accept(value) + if len(f.cache) < memoizingFilterMaxEntries && f.bytes+len(value) <= memoizingFilterMaxBytes { + f.cache[value] = memoEntry{accepted: accepted, score: score} + f.bytes += len(value) + } + return accepted, score +} + +// ChainFilter combines multiple filters with AND logic. +// Returns true only if all filters accept the value. +// The returned score is the best (max) score across the filters, so that +// rankings reflect the strongest matching dimension. +type ChainFilter struct { + filters []storage.Filter +} + +// NewChainFilter creates a new ChainFilter. +func NewChainFilter(filters ...storage.Filter) *ChainFilter { + return &ChainFilter{ + filters: filters, + } +} + +// Accept returns true if all filters accept the value. +// Returns the maximum score from all filters. +func (f *ChainFilter) Accept(value string) (bool, float64) { + if len(f.filters) == 0 { + return true, 1.0 + } + + var maxScore float64 + for _, filter := range f.filters { + accepted, score := filter.Accept(value) + if !accepted { + return false, 0.0 + } + if score > maxScore { + maxScore = score + } + } + + return true, maxScore +} + +// orSearchesFilter combines multiple per-term filters with OR logic. +// Returns true if any term filter accepts the value. +// The returned score is the maximum score from all accepting filters. +type orSearchesFilter struct { + filters []storage.Filter +} + +func newOrSearchesFilter(filters ...storage.Filter) *orSearchesFilter { + return &orSearchesFilter{filters: filters} +} + +// Accept returns true if any of the per-term filters accepts the value. +// Stops iterating once a perfect (1.0) score is found. +func (f *orSearchesFilter) Accept(value string) (bool, float64) { + var maxScore float64 + accepted := false + for _, filter := range f.filters { + ok, score := filter.Accept(value) + if !ok { + continue + } + accepted = true + if score > maxScore { + maxScore = score + } + if maxScore >= 1.0 { + return true, maxScore + } + } + return accepted, maxScore +} + +// orFilter combines substring and fuzzy filters with OR logic. +// Tries substring first, then fuzzy if substring doesn't match. +type orFilter struct { + substringFilter *SubstringFilter + fuzzyFilter *FuzzyFilter +} + +// Accept returns true if either substring or fuzzy filter accepts. +func (f *orFilter) Accept(value string) (bool, float64) { + // If no filters, accept all. + if f.substringFilter == nil && f.fuzzyFilter == nil { + return true, 1.0 + } + + // Try substring first. + if f.substringFilter != nil { + accepted, score := f.substringFilter.Accept(value) + if accepted { + return true, score + } + } + + // Fall back to fuzzy if available. + if f.fuzzyFilter != nil { + return f.fuzzyFilter.Accept(value) + } + + // No filter accepted. + return false, 0.0 +} diff --git a/web/api/v1/search_filters_bench_test.go b/web/api/v1/search_filters_bench_test.go new file mode 100644 index 0000000000..55565b8245 --- /dev/null +++ b/web/api/v1/search_filters_bench_test.go @@ -0,0 +1,118 @@ +// 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 ( + "fmt" + "testing" + + "github.com/prometheus/prometheus/storage" +) + +// benchValues returns a slice of N pseudo-metric names used as Filter inputs. +// The shape mirrors typical metric_name cardinality: a small number of common +// prefixes followed by a descriptor. +func benchValues(n int) []string { + prefixes := []string{ + "http_requests_total", + "go_gc_duration_seconds", + "go_memstats_alloc_bytes", + "prometheus_tsdb_head_series", + "node_cpu_seconds_total", + "node_memory_MemAvailable_bytes", + "container_cpu_usage_seconds_total", + "kube_pod_status_phase", + } + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("%s_%07d", prefixes[i%len(prefixes)], i) + } + return out +} + +func BenchmarkSubstringFilter(b *testing.B) { + values := benchValues(10000) + filter := NewSubstringFilter("requests") + b.ReportAllocs() + for b.Loop() { + for _, v := range values { + _, _ = filter.Accept(v) + } + } +} + +func BenchmarkFuzzyFilter(b *testing.B) { + values := benchValues(10000) + filter := NewFuzzyFilter("requests", 0.6) + b.ReportAllocs() + for b.Loop() { + for _, v := range values { + _, _ = filter.Accept(v) + } + } +} + +func BenchmarkSubsequenceFilter(b *testing.B) { + values := benchValues(10000) + filter := NewSubsequenceFilter("rqts", 0.0) + b.ReportAllocs() + for b.Loop() { + for _, v := range values { + _, _ = filter.Accept(v) + } + } +} + +// BenchmarkFilterChainAcrossBlocks models the multi-block case that motivates +// memoization: each value is scored once per simulated block. The "blocks" +// dimension is the multiplier that the cache eliminates for shared values. +// +// Each filter type has paired sub-benchmarks — one with the raw filter and one +// wrapped in memoizingFilter — so benchstat can quantify the memoization win +// for the same scoring algorithm rather than comparing across algorithms. +func BenchmarkFilterChainAcrossBlocks(b *testing.B) { + const blocks = 24 + values := benchValues(2000) + + type filterCase struct { + name string + raw func() storage.Filter + } + cases := []filterCase{ + {"substring", func() storage.Filter { return NewSubstringFilter("requests") }}, + {"jarowinkler", func() storage.Filter { return NewFuzzyFilter("requests", 0.6) }}, + {"subsequence", func() storage.Filter { return NewSubsequenceFilter("rqts", 0.0) }}, + } + + run := func(b *testing.B, build func() storage.Filter) { + b.ReportAllocs() + for b.Loop() { + filter := build() + for range blocks { + for _, v := range values { + _, _ = filter.Accept(v) + } + } + } + } + + for _, c := range cases { + b.Run(c.name+"/raw", func(b *testing.B) { + run(b, c.raw) + }) + b.Run(c.name+"/memo", func(b *testing.B) { + run(b, func() storage.Filter { return newMemoizingFilter(c.raw()) }) + }) + } +} diff --git a/web/api/v1/search_filters_test.go b/web/api/v1/search_filters_test.go new file mode 100644 index 0000000000..7ef1bdef3a --- /dev/null +++ b/web/api/v1/search_filters_test.go @@ -0,0 +1,587 @@ +// 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 ( + "fmt" + "strconv" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/storage" +) + +func TestSubstringFilter(t *testing.T) { + tests := []struct { + name string + query string + value string + wantAccepted bool + wantScore float64 + }{ + { + name: "exact match", + query: "prometheus", + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "prefix match", + query: "prom", + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "substring match in middle", + query: "meth", + value: "prometheus", + wantAccepted: true, + // idx=3, maxIdx=10-4=6 -> 1.0 - 0.9*3/6 = 0.55. + wantScore: 0.55, + }, + { + name: "substring match at end", + query: "heus", + value: "prometheus", + wantAccepted: true, + // idx=6, maxIdx=10-4=6 -> 1.0 - 0.9 = 0.1. + wantScore: 0.1, + }, + { + name: "case mismatch is not accepted", + query: "prometheus", + value: "Prometheus", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "no match", + query: "grafana", + value: "prometheus", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "empty query accepts all", + query: "", + value: "anything", + wantAccepted: true, + wantScore: 1.0, + }, + { + // "本" appears at byte offset 6 (after "東京日") but at rune + // offset 3. The pre-rune scoring would return ~0.55; rune + // scoring returns 0.55 for offset 3 of 6 since maxPos=6. + // "東京日本京都" has 6 runes; query "本" has 1; maxPos=5; + // pos=3 -> 1.0 - 0.9*3/5 = 0.46. + name: "multi-byte rune position", + query: "本", + value: "東京日本京都", + wantAccepted: true, + wantScore: 0.46, + }, + { + // ASCII fast path remains correct: identical scoring to + // the original implementation for the common case. + name: "ascii substring stays on fast path", + query: "fo", + value: "infor", + wantAccepted: true, + // idx=2, maxIdx=5-2=3 -> 1.0 - 0.9*2/3 = 0.4. + wantScore: 0.4, + }, + { + // ASCII query, ASCII match prefix, multi-byte suffix: the + // fast path must NOT trigger because len(value) overcounts + // runes and would inflate the score. Expected rune-based: + // runes("abc日本")=5, runes("b")=1 -> maxPos=4, pos=1 -> + // 1.0 - 0.9*1/4 = 0.775. + name: "ascii match with multi-byte suffix uses rune positions", + query: "b", + value: "abc日本", + wantAccepted: true, + wantScore: 0.775, + }, + { + // Symmetric case: multi-byte prefix forces the rune path + // for the position computation. + name: "multi-byte prefix uses rune positions", + query: "c", + value: "日本cabc", + wantAccepted: true, + // runes("日本cabc")=6, query=1 -> maxPos=5, pos=2 (after + // "日本") -> 1.0 - 0.9*2/5 = 0.64. + wantScore: 0.64, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := NewSubstringFilter(tt.query) + accepted, score := filter.Accept(tt.value) + require.Equal(t, tt.wantAccepted, accepted) + require.InDelta(t, tt.wantScore, score, 0.01) + }) + } +} + +func TestFuzzyFilter(t *testing.T) { + tests := []struct { + name string + query string + threshold float64 + value string + wantAccepted bool + minScore float64 // Minimum expected score. + }{ + { + name: "exact match", + query: "prometheus", + threshold: 0.8, + value: "prometheus", + wantAccepted: true, + minScore: 1.0, + }, + { + name: "close match above threshold", + query: "prometheus", + threshold: 0.8, + value: "promethus", // Typo: one char different. + wantAccepted: true, + minScore: 0.8, + }, + { + name: "distant match below threshold", + query: "prometheus", + threshold: 0.8, + value: "grafana", + wantAccepted: false, + minScore: 0.0, + }, + { + name: "case mismatch is not accepted", + query: "prometheus", + threshold: 0.8, + value: "PROMETHEUS", + wantAccepted: false, + minScore: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := NewFuzzyFilter(tt.query, tt.threshold) + accepted, score := filter.Accept(tt.value) + require.Equal(t, tt.wantAccepted, accepted) + if tt.wantAccepted { + require.GreaterOrEqual(t, score, tt.minScore) + } + }) + } +} + +func TestFuzzyFilterConcurrency(_ *testing.T) { + filter := NewFuzzyFilter("prometheus", 0.8) + values := []string{"prometheus", "promethus", "promethius", "prmetheus", "prometeus"} //nolint:misspell + + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + for _, value := range values { + _, _ = filter.Accept(value) + } + }) + } + + wg.Wait() +} + +func TestSubsequenceFilter(t *testing.T) { + tests := []struct { + name string + query string + threshold float64 + value string + wantAccepted bool + wantScore float64 // -1 means "any positive value". + }{ + { + name: "exact match", + query: "prometheus", + threshold: 1.0, + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "prefix match scores 1.0", + query: "prom", + threshold: 1.0, + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "subsequence match above zero threshold", + query: "pms", + threshold: 0.0, + value: "prometheus", + wantAccepted: true, + wantScore: -1, + }, + { + name: "non-subsequence rejected", + query: "xyz", + threshold: 0.0, + value: "prometheus", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "case mismatch rejected", + query: "prom", + threshold: 0.0, + value: "PROMETHEUS", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "below threshold rejected", + query: "pms", + threshold: 0.99, + value: "prometheus", + wantAccepted: false, + wantScore: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := NewSubsequenceFilter(tt.query, tt.threshold) + accepted, score := filter.Accept(tt.value) + require.Equal(t, tt.wantAccepted, accepted) + if tt.wantScore >= 0 { + require.InDelta(t, tt.wantScore, score, 1e-9) + } + }) + } +} + +// countingFilter records how many times Accept is called per value. +type countingFilter struct { + mu sync.Mutex + calls map[string]int + result map[string]memoEntry +} + +func (f *countingFilter) Accept(value string) (bool, float64) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls[value]++ + r := f.result[value] + return r.accepted, r.score +} + +func TestMemoizingFilter(t *testing.T) { + inner := &countingFilter{ + calls: map[string]int{}, + result: map[string]memoEntry{ + "prometheus": {accepted: true, score: 1.0}, + "grafana": {accepted: false, score: 0.0}, + }, + } + memo := newMemoizingFilter(inner) + + // First call computes. + accepted, score := memo.Accept("prometheus") + require.True(t, accepted) + require.Equal(t, 1.0, score) + + // Repeat calls hit the cache. + for range 5 { + accepted, score = memo.Accept("prometheus") + require.True(t, accepted) + require.Equal(t, 1.0, score) + } + + // Distinct values are computed once each. + accepted, score = memo.Accept("grafana") + require.False(t, accepted) + require.Equal(t, 0.0, score) + memo.Accept("grafana") + + require.Equal(t, 1, inner.calls["prometheus"]) + require.Equal(t, 1, inner.calls["grafana"]) +} + +func TestMemoizingFilter_CacheCap(t *testing.T) { + // Build a result map slightly larger than the memo cap so we can observe + // the inner filter being recalled for values past the cap. + results := make(map[string]memoEntry, memoizingFilterMaxEntries+10) + for i := range memoizingFilterMaxEntries + 10 { + results[strconv.Itoa(i)] = memoEntry{accepted: true, score: 1.0} + } + inner := &countingFilter{ + calls: map[string]int{}, + result: results, + } + memo := newMemoizingFilter(inner) + + // Fill the cache to its cap. + for i := range memoizingFilterMaxEntries { + memo.Accept(strconv.Itoa(i)) + } + require.Len(t, memo.cache, memoizingFilterMaxEntries) + + // A value past the cap is computed by inner but not stored. + const overflow = "overflow-value" + inner.result[overflow] = memoEntry{accepted: true, score: 0.5} + memo.Accept(overflow) + memo.Accept(overflow) + require.Len(t, memo.cache, memoizingFilterMaxEntries) + // Past the cap, every Accept reaches the inner filter. + require.Equal(t, 2, inner.calls[overflow]) + + // Cached values are still served from the cache. + memo.Accept("0") + require.Equal(t, 1, inner.calls["0"]) +} + +func TestMemoizingFilter_ByteCap(t *testing.T) { + // Construct distinct values long enough that the byte cap is reached + // well before the entry cap. Each key contributes len(value) bytes to + // f.bytes. + const valueLen = 1024 + entriesToOverflow := memoizingFilterMaxBytes/valueLen + 2 + require.Less(t, entriesToOverflow, memoizingFilterMaxEntries, + "test setup: byte cap must be reached before entry cap") + + keys := make([]string, 0, entriesToOverflow) + results := make(map[string]memoEntry, entriesToOverflow) + for i := range entriesToOverflow { + prefix := fmt.Sprintf("%010d:", i) + key := prefix + strings.Repeat("x", valueLen-len(prefix)) + keys = append(keys, key) + results[key] = memoEntry{accepted: true, score: 1.0} + } + require.Len(t, keys[0], valueLen) + + inner := &countingFilter{ + calls: map[string]int{}, + result: results, + } + memo := newMemoizingFilter(inner) + + for _, k := range keys { + memo.Accept(k) + } + + // The byte cap must have stopped insertion before all entries fit. + require.Less(t, len(memo.cache), entriesToOverflow, + "byte cap must limit cached entries below the inserted count") + require.Less(t, len(memo.cache), memoizingFilterMaxEntries, + "byte cap must be reached before the entry cap") + require.LessOrEqual(t, memo.bytes, memoizingFilterMaxBytes, + "cached bytes must not exceed the configured budget") + + // A value past the cap is recomputed by the inner filter each call. + overflowKey := keys[entriesToOverflow-1] + callsBefore := inner.calls[overflowKey] + require.NotContains(t, memo.cache, overflowKey, + "the last inserted key must be past the byte cap and therefore not cached") + memo.Accept(overflowKey) + memo.Accept(overflowKey) + require.Equal(t, callsBefore+2, inner.calls[overflowKey], + "values past the byte cap must reach the inner filter on every Accept") +} + +func TestBuildSearchFilter_MemoOnlyForExpensiveChain(t *testing.T) { + // Substring-only chain: jaro-winkler with no fuzz threshold. Memo is + // not added because substring scoring is already O(L). + got := buildSearchFilter([]string{"prom"}, 0, "jarowinkler", true) + _, isMemo := got.(*memoizingFilter) + require.False(t, isMemo, "substring-only chain should not be wrapped with memoizingFilter") + + // Subsequence: always memoized. + got = buildSearchFilter([]string{"prm"}, 0, "subsequence", true) + _, isMemo = got.(*memoizingFilter) + require.True(t, isMemo, "subsequence chain must be memoized") + + // Jaro-Winkler with a non-zero fuzz threshold: memoized. + got = buildSearchFilter([]string{"prom"}, 80, "jarowinkler", true) + _, isMemo = got.(*memoizingFilter) + require.True(t, isMemo, "jaro-winkler with fuzz threshold must be memoized") +} + +func TestCaseFoldingFilter(t *testing.T) { + // Inner filter expects lowercased query and value. + inner := NewSubstringFilter("prom") + wrapped := newCaseFoldingFilter(inner) + + accepted, score := wrapped.Accept("Prometheus") + require.True(t, accepted) + require.Equal(t, 1.0, score) + + accepted, _ = wrapped.Accept("Grafana") + require.False(t, accepted) +} + +func TestOrFilter(t *testing.T) { + tests := []struct { + name string + substringQuery string + fuzzyQuery string + fuzzyThreshold float64 + value string + wantAccepted bool + minScore float64 + }{ + { + name: "substring match only", + substringQuery: "prom", + value: "prometheus", + wantAccepted: true, + minScore: 1.0, // Prefix match. + }, + { + name: "substring rejects", + substringQuery: "prom", + value: "node", + wantAccepted: false, + }, + { + name: "fuzzy fallback", + substringQuery: "go_gor", + fuzzyQuery: "go_gor", + fuzzyThreshold: 0.8, + value: "go_goroutins", // Not substring, but fuzzy matches. + wantAccepted: true, + minScore: 0.8, + }, + { + name: "no filters accept all", + value: "anything", + wantAccepted: true, + minScore: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var substringFilter *SubstringFilter + var fuzzyFilter *FuzzyFilter + + if tt.substringQuery != "" { + substringFilter = NewSubstringFilter(tt.substringQuery) + } + if tt.fuzzyQuery != "" { + fuzzyFilter = NewFuzzyFilter(tt.fuzzyQuery, tt.fuzzyThreshold) + } + + filter := &orFilter{substringFilter: substringFilter, fuzzyFilter: fuzzyFilter} + accepted, score := filter.Accept(tt.value) + + require.Equal(t, tt.wantAccepted, accepted) + if tt.wantAccepted { + require.GreaterOrEqual(t, score, tt.minScore) + } + }) + } +} + +func TestChainFilter(t *testing.T) { + type filterSpec struct { + query string + threshold float64 + isSubstring bool + } + tests := []struct { + name string + filters []filterSpec + value string + wantAccepted bool + wantScore float64 + }{ + { + name: "both filters accept", + filters: []filterSpec{ + {query: "prom", isSubstring: true}, + {query: "prometheus", threshold: 0.8}, + }, + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "first filter rejects", + filters: []filterSpec{ + {query: "grafana", isSubstring: true}, + {query: "prometheus", threshold: 0.8}, + }, + value: "prometheus", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "second filter rejects", + filters: []filterSpec{ + {query: "prom", isSubstring: true}, + {query: "grafana", threshold: 0.9}, + }, + value: "prometheus", + wantAccepted: false, + wantScore: 0.0, + }, + { + name: "empty chain accepts all", + filters: nil, + value: "anything", + wantAccepted: true, + wantScore: 1.0, + }, + { + name: "max score wins for ranking", + filters: []filterSpec{ + {query: "prom", isSubstring: true}, // Score: 1.0 (prefix). + {query: "prometheus", threshold: 0.5}, // Score: 1.0 (exact). + }, + value: "prometheus", + wantAccepted: true, + wantScore: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var filters []storage.Filter + for _, f := range tt.filters { + if f.isSubstring { + filters = append(filters, NewSubstringFilter(f.query)) + } else { + filters = append(filters, NewFuzzyFilter(f.query, f.threshold)) + } + } + + chain := NewChainFilter(filters...) + accepted, score := chain.Accept(tt.value) + require.Equal(t, tt.wantAccepted, accepted) + require.InDelta(t, tt.wantScore, score, 1e-9) + }) + } +} diff --git a/web/api/v1/search_test.go b/web/api/v1/search_test.go new file mode 100644 index 0000000000..b2e104a358 --- /dev/null +++ b/web/api/v1/search_test.go @@ -0,0 +1,1282 @@ +// 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) + }) + + t.Run("batch_size zero uses server default", func(t *testing.T) { + rec := doSearchRequest(t, api, "/search/metric_names", url.Values{ + "batch_size": []string{"0"}, + }) + require.Equal(t, http.StatusOK, rec.Code) + + lines := parseNDJSON(t, rec.Body.String()) + // With batch_size=0 (server default 100), all 5 results fit in one batch + trailer. + require.Len(t, lines, 2) + }) + + 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) + }) +} + +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) + }) + + 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") +} diff --git a/web/api/v1/testdata/openapi_3.1_golden.yaml b/web/api/v1/testdata/openapi_3.1_golden.yaml index 0d15e1f407..f675514dc8 100644 --- a/web/api/v1/testdata/openapi_3.1_golden.yaml +++ b/web/api/v1/testdata/openapi_3.1_golden.yaml @@ -1044,6 +1044,725 @@ paths: error: TSDB not ready errorType: internal status: error + /search/metric_names: + get: + tags: + - metadata + summary: Search metric names + operationId: search-metric-names + parameters: + - name: match[] + in: query + description: Series selector argument used to scope metric discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - '{job="prometheus"}' + - name: search[] + in: query + description: One or more search terms matched against metric names (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - http_req + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: include_metadata + in: query + description: Include metric metadata in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for metric name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for metric name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of metric names to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + responses: + "200": + description: Metric names streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + metricNamesStream: + summary: NDJSON stream of metric names + value: | + {"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} + {"status":"success","has_more":false} + default: + description: Error searching metric names. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - metadata + summary: Search metric names + operationId: search-metric-names-post + requestBody: + description: Submit a metric name search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchMetricNamesPostInputBody' + examples: + metricAutocomplete: + summary: Search metric names for autocomplete + value: + include_metadata: true + limit: 20 + search[]: + - http_req + sort_by: score + required: true + responses: + "200": + description: Metric names streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + metricNamesStream: + summary: NDJSON stream of metric names + value: | + {"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} + {"status":"success","has_more":false} + default: + description: Error searching metric names via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + /search/label_names: + get: + tags: + - labels + summary: Search label names + operationId: search-label-names + parameters: + - name: match[] + in: query + description: Series selector argument used to scope label discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - '{__name__="up"}' + - name: search[] + in: query + description: One or more search terms matched against label names (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - inst + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for label name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for label name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of label names to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + responses: + "200": + description: Label names streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelNamesStream: + summary: NDJSON stream of label names + value: | + {"results":[{"name":"instance"},{"name":"job"}]} + {"status":"success","has_more":false} + default: + description: Error searching label names. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - labels + summary: Search label names + operationId: search-label-names-post + requestBody: + description: Submit a label name search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchLabelNamesPostInputBody' + examples: + labelsForMetric: + summary: Search label names for a metric + value: + limit: 20 + match[]: + - '{__name__="http_requests_total"}' + search[]: + - sta + sort_by: score + required: true + responses: + "200": + description: Label names streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelNamesStream: + summary: NDJSON stream of label names + value: | + {"results":[{"name":"instance"},{"name":"job"}]} + {"status":"success","has_more":false} + default: + description: Error searching label names via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + /search/label_values: + get: + tags: + - labels + summary: Search label values + operationId: search-label-values + parameters: + - name: label + in: query + description: Label name whose values should be searched. + required: true + explode: false + schema: + type: string + examples: + example: + value: instance + - name: match[] + in: query + description: Series selector argument used to scope label value discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - up + - name: search[] + in: query + description: One or more search terms matched against label values (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - "909" + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for label value search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for label value search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of label values to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 10 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 10 + responses: + "200": + description: Label values streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelValuesStream: + summary: NDJSON stream of label values + value: | + {"results":[{"value":"localhost:9090"},{"value":"localhost:9091"}]} + {"status":"success","has_more":true} + default: + description: Error searching label values. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - labels + summary: Search label values + operationId: search-label-values-post + requestBody: + description: Submit a label value search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchLabelValuesPostInputBody' + examples: + valuesForLabel: + summary: Search values for a label + value: + label: instance + limit: 10 + match[]: + - up + search[]: + - "909" + sort_by: score + required: true + responses: + "200": + description: Label values streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelValuesStream: + summary: NDJSON stream of label values + value: | + {"results":[{"value":"localhost:9090"},{"value":"localhost:9091"}]} + {"status":"success","has_more":true} + default: + description: Error searching label values via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error /series: get: tags: @@ -3249,6 +3968,222 @@ components: - data additionalProperties: false description: Response body with an array of strings. + SearchMetricNamesPostInputBody: + type: object + properties: + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope metric discovery.' + example: + - '{job="prometheus"}' + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against metric names (OR logic).' + example: + - http_req + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + include_metadata: + type: boolean + description: 'Form field: Include metric metadata in each result.' + additionalProperties: false + description: POST request body for metric name search. + SearchLabelNamesPostInputBody: + type: object + properties: + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope label discovery.' + example: + - '{__name__="up"}' + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against label names (OR logic).' + example: + - inst + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + additionalProperties: false + description: POST request body for label name search. + SearchLabelValuesPostInputBody: + type: object + properties: + label: + type: string + description: 'Form field: Label name whose values should be searched.' + example: instance + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope label value discovery.' + example: + - up + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against label values (OR logic).' + example: + - "909" + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + required: + - label + additionalProperties: false + description: POST request body for label value search. SeriesOutputBody: 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 ecbece1b1f..459d8b5086 100644 --- a/web/api/v1/testdata/openapi_3.2_golden.yaml +++ b/web/api/v1/testdata/openapi_3.2_golden.yaml @@ -1044,6 +1044,725 @@ paths: error: TSDB not ready errorType: internal status: error + /search/metric_names: + get: + tags: + - metadata + summary: Search metric names + operationId: search-metric-names + parameters: + - name: match[] + in: query + description: Series selector argument used to scope metric discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - '{job="prometheus"}' + - name: search[] + in: query + description: One or more search terms matched against metric names (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - http_req + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: include_metadata + in: query + description: Include metric metadata in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for metric name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for metric name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of metric names to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + responses: + "200": + description: Metric names streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + metricNamesStream: + summary: NDJSON stream of metric names + value: | + {"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} + {"status":"success","has_more":false} + default: + description: Error searching metric names. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - metadata + summary: Search metric names + operationId: search-metric-names-post + requestBody: + description: Submit a metric name search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchMetricNamesPostInputBody' + examples: + metricAutocomplete: + summary: Search metric names for autocomplete + value: + include_metadata: true + limit: 20 + search[]: + - http_req + sort_by: score + required: true + responses: + "200": + description: Metric names streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + metricNamesStream: + summary: NDJSON stream of metric names + value: | + {"results":[{"name":"http_requests_total","type":"counter","help":"Total HTTP requests."}]} + {"status":"success","has_more":false} + default: + description: Error searching metric names via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + /search/label_names: + get: + tags: + - labels + summary: Search label names + operationId: search-label-names + parameters: + - name: match[] + in: query + description: Series selector argument used to scope label discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - '{__name__="up"}' + - name: search[] + in: query + description: One or more search terms matched against label names (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - inst + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for label name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for label name search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of label names to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 20 + responses: + "200": + description: Label names streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelNamesStream: + summary: NDJSON stream of label names + value: | + {"results":[{"name":"instance"},{"name":"job"}]} + {"status":"success","has_more":false} + default: + description: Error searching label names. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - labels + summary: Search label names + operationId: search-label-names-post + requestBody: + description: Submit a label name search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchLabelNamesPostInputBody' + examples: + labelsForMetric: + summary: Search label names for a metric + value: + limit: 20 + match[]: + - '{__name__="http_requests_total"}' + search[]: + - sta + sort_by: score + required: true + responses: + "200": + description: Label names streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelNamesStream: + summary: NDJSON stream of label names + value: | + {"results":[{"name":"instance"},{"name":"job"}]} + {"status":"success","has_more":false} + default: + description: Error searching label names via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + /search/label_values: + get: + tags: + - labels + summary: Search label values + operationId: search-label-values + parameters: + - name: label + in: query + description: Label name whose values should be searched. + required: true + explode: false + schema: + type: string + examples: + example: + value: instance + - name: match[] + in: query + description: Series selector argument used to scope label value discovery. + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - up + - name: search[] + in: query + description: One or more search terms matched against label values (OR logic). + required: false + explode: false + schema: + type: array + items: + type: string + examples: + example: + value: + - "909" + - name: fuzz_threshold + in: query + description: Fuzzy threshold in the range 0-100. A value of 0 is the lowest fuzzy threshold. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 80 + - name: fuzz_alg + in: query + description: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler. + required: false + explode: false + schema: + type: string + enum: + - subsequence + - jarowinkler + example: subsequence + - name: case_sensitive + in: query + description: Whether matching is case-sensitive. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: sort_by + in: query + description: Sort mode. Supported values are alpha and score. + required: false + explode: false + schema: + type: string + enum: + - alpha + - score + example: alpha + - name: sort_dir + in: query + description: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc. + required: false + explode: false + schema: + type: string + enum: + - asc + - dsc + example: asc + - name: include_score + in: query + description: Include the relevance score in each result. + required: false + explode: false + schema: + type: boolean + examples: + example: + value: true + - name: start + in: query + description: Start timestamp for label value search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T12:37:00Z" + epoch: + value: 1767357420 + - name: end + in: query + description: End timestamp for label value search. + required: false + explode: false + schema: + oneOf: + - type: string + format: date-time + description: RFC3339 timestamp. + - type: number + format: unixtime + description: Unix timestamp in seconds. + description: Timestamp in RFC3339 format or Unix timestamp in seconds. + examples: + RFC3339: + value: "2026-01-02T13:37:00Z" + epoch: + value: 1767361020 + - name: limit + in: query + description: Maximum number of label values to return. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 10 + - name: batch_size + in: query + description: Preferred number of results per NDJSON batch. + required: false + explode: false + schema: + type: integer + format: int64 + examples: + example: + value: 10 + responses: + "200": + description: Label values streamed successfully. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelValuesStream: + summary: NDJSON stream of label values + value: | + {"results":[{"value":"localhost:9090"},{"value":"localhost:9091"}]} + {"status":"success","has_more":true} + default: + description: Error searching label values. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error + post: + tags: + - labels + summary: Search label values + operationId: search-label-values-post + requestBody: + description: Submit a label value search. This endpoint accepts the same parameters as the GET version. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SearchLabelValuesPostInputBody' + examples: + valuesForLabel: + summary: Search values for a label + value: + label: instance + limit: 10 + match[]: + - up + search[]: + - "909" + sort_by: score + required: true + responses: + "200": + description: Label values streamed successfully via POST. + content: + application/x-ndjson: + schema: + type: string + description: NDJSON response stream. + examples: + labelValuesStream: + summary: NDJSON stream of label values + value: | + {"results":[{"value":"localhost:9090"},{"value":"localhost:9091"}]} + {"status":"success","has_more":true} + default: + description: Error searching label values via POST. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + tsdbNotReady: + summary: TSDB not ready + value: + error: TSDB not ready + errorType: internal + status: error /series: get: tags: @@ -3287,6 +4006,222 @@ components: - data additionalProperties: false description: Response body with an array of strings. + SearchMetricNamesPostInputBody: + type: object + properties: + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope metric discovery.' + example: + - '{job="prometheus"}' + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against metric names (OR logic).' + example: + - http_req + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + include_metadata: + type: boolean + description: 'Form field: Include metric metadata in each result.' + additionalProperties: false + description: POST request body for metric name search. + SearchLabelNamesPostInputBody: + type: object + properties: + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope label discovery.' + example: + - '{__name__="up"}' + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against label names (OR logic).' + example: + - inst + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + additionalProperties: false + description: POST request body for label name search. + SearchLabelValuesPostInputBody: + type: object + properties: + label: + type: string + description: 'Form field: Label name whose values should be searched.' + example: instance + match[]: + type: array + items: + type: string + description: 'Form field: Series selector argument used to scope label value discovery.' + example: + - up + search[]: + type: array + items: + type: string + description: 'Form field: One or more search terms matched against label values (OR logic).' + example: + - "909" + fuzz_threshold: + type: integer + format: int64 + description: 'Form field: Fuzzy threshold in the range 0-100. Default is 0, the lowest fuzzy threshold.' + example: 80 + fuzz_alg: + type: string + enum: + - subsequence + - jarowinkler + description: 'Form field: Fuzzy algorithm. Supported values are subsequence (default) and jarowinkler.' + example: subsequence + case_sensitive: + type: boolean + description: 'Form field: Whether matching is case-sensitive.' + sort_by: + type: string + enum: + - alpha + - score + description: 'Form field: Sort mode. Supported values are alpha and score. If unset, results are returned in natural order.' + example: alpha + sort_dir: + type: string + enum: + - asc + - dsc + description: 'Form field: Sort direction. Only valid with sort_by=alpha. Supported values are asc and dsc.' + example: asc + include_score: + type: boolean + description: 'Form field: Include the relevance score in each result record.' + start: + type: string + description: 'Form field: The start time of the query.' + example: "2026-01-02T12:37:00.000Z" + end: + type: string + description: 'Form field: The end time of the query.' + example: "2026-01-02T13:37:00.000Z" + limit: + type: integer + format: int64 + description: 'Form field: The maximum number of results to return.' + example: 20 + batch_size: + type: integer + format: int64 + description: 'Form field: Preferred number of results per NDJSON batch.' + example: 20 + required: + - label + additionalProperties: false + description: POST request body for label value search. SeriesOutputBody: type: object properties: diff --git a/web/web.go b/web/web.go index 51316e95c0..dba72a1aa0 100644 --- a/web/web.go +++ b/web/web.go @@ -298,6 +298,8 @@ type Options struct { UseOldUI bool EnableLifecycle bool EnableAdminAPI bool + EnableSearch bool + MaxSearchLimit int PageTitle string RemoteReadSampleLimit int RemoteReadConcurrencyLimit int @@ -404,6 +406,8 @@ func New(logger *slog.Logger, o *Options) *Handler { h.options.LocalStorage, h.options.TSDBDir, h.options.EnableAdminAPI, + h.options.EnableSearch, + h.options.MaxSearchLimit, logger, FactoryRr, h.options.RemoteReadSampleLimit, @@ -442,6 +446,10 @@ func New(logger *slog.Logger, o *Options) *Handler { r.Set(features.API, "admin", o.EnableAdminAPI) r.Set(features.API, "remote_write_receiver", o.EnableRemoteWriteReceiver) r.Set(features.API, "otlp_write_receiver", o.EnableOTLPWriteReceiver) + r.Set(features.API, "search", o.EnableSearch) + for _, alg := range api_v1.FuzzAlgorithms() { + r.Enable(features.API, "search_fuzz_alg_"+alg) + } r.Set(features.OTLPReceiver, "delta_conversion", o.ConvertOTLPDelta) r.Set(features.OTLPReceiver, "native_delta_ingestion", o.NativeOTLPDeltaIngestion) r.Enable(features.API, "label_values_match") // match[] parameter for label values endpoint.