web/api: add search API endpoint (#18573)

Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
This commit is contained in:
Julien 2026-05-19 13:58:00 +02:00 committed by GitHub
parent ee7fea7f14
commit e1f4380b2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 6225 additions and 54 deletions

View file

@ -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 {

View file

@ -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
},

View file

@ -51,6 +51,7 @@ The Prometheus monitoring server
| <code class="text-nowrap">--storage.remote.read-sample-limit</code> | 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` |
| <code class="text-nowrap">--storage.remote.read-concurrent-limit</code> | Maximum number of concurrent remote read calls. 0 means no limit. Use with server mode only. | `10` |
| <code class="text-nowrap">--storage.remote.read-max-bytes-in-frame</code> | 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` |
| <code class="text-nowrap">--web.search.max-limit</code> | 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` |
| <code class="text-nowrap">--rules.alert.for-outage-tolerance</code> | Max time to tolerate prometheus outage for restoring "for" state of alert. Use with server mode only. | `1h` |
| <code class="text-nowrap">--rules.alert.for-grace-period</code> | 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` |
| <code class="text-nowrap">--rules.alert.resend-delay</code> | 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
| <code class="text-nowrap">--query.timeout</code> | Maximum time a query may take before being aborted. Use with server mode only. | `2m` |
| <code class="text-nowrap">--query.max-concurrency</code> | Maximum number of queries executed concurrently. Use with server mode only. | `20` |
| <code class="text-nowrap">--query.max-samples</code> | 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` |
| <code class="text-nowrap">--enable-feature</code> <code class="text-nowrap">...<code class="text-nowrap"> | 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. | |
| <code class="text-nowrap">--enable-feature</code> <code class="text-nowrap">...<code class="text-nowrap"> | 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. | |
| <code class="text-nowrap">--agent</code> | Run Prometheus in 'Agent mode'. | |
| <code class="text-nowrap">--log.level</code> | Only log messages with the given severity or above. One of: [debug, info, warn, error] | `info` |
| <code class="text-nowrap">--log.format</code> | Output format of log messages. One of: [logfmt, json] | `logfmt` |

View file

@ -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.

View file

@ -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[]=<series_selector>`: Repeated series selector used to scope the
search. Optional.
- `search[]=<string>`: Repeated search string matched against names or values.
Multiple values use OR semantics. Optional.
- `fuzz_threshold=<number>`: Fuzzy threshold from 0 to 100. Optional. A value
of 0 is the lowest fuzzy threshold.
- `fuzz_alg=<subsequence | jarowinkler>`: Matching algorithm. Optional. Default
is `subsequence`.
- `case_sensitive=<bool>`: Toggle case-sensitive matching. Optional.
- `sort_by=<string>`: Sort mode. Supported values depend on the endpoint.
- `sort_dir=<asc | dsc>`: Sort direction. Optional. Only valid with
`sort_by=alpha`.
- `include_score=<bool>`: Include the relevance score in each result. Optional.
- `start=<rfc3339 | unix_timestamp>`: Start timestamp. Optional.
- `end=<rfc3339 | unix_timestamp>`: End timestamp. Optional.
- `limit=<number>`: Maximum number of returned results. Optional. Default is
100.
- `batch_size=<number>`: 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=<bool>`: Include metric metadata in each result.
- `sort_by=<alpha | score>`
Additional parameters for `/api/v1/search/label_names`:
- `sort_by=<alpha | score>`
Additional parameters for `/api/v1/search/label_values`:
- `label=<label_name>`: Label name whose values should be searched. Required.
- `sort_by=<alpha | score>`
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.

View file

@ -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

329
storage/generic_test.go Normal file
View file

@ -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)
}
})
}
}

View file

@ -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}},

View file

@ -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))

View file

@ -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.

View file

@ -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))

View file

@ -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())

View file

@ -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]()

View file

@ -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]()

View file

@ -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))),

View file

@ -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)."))

842
web/api/v1/search.go Normal file
View file

@ -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 13 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
})
}

View file

@ -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
}

View file

@ -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()) })
})
}
}

View file

@ -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)
})
}
}

1282
web/api/v1/search_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -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:

View file

@ -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:

View file

@ -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.