diff --git a/docs/querying/api.md b/docs/querying/api.md index c5dc82c460..89470f0a2b 100644 --- a/docs/querying/api.md +++ b/docs/querying/api.md @@ -541,10 +541,10 @@ URL query parameters: - `metric_match=~.*_info` - regex match (`__name__=~".*_info"`) - `metric_match=!=target_info` - negated exact match (`__name__!="target_info"`) - `metric_match=!~build_info` - negated regex match (`__name__!~"build_info"`) -- `match[]=`: Repeated series selector argument that filters - which info metrics are considered based on matching identifying labels. - For example, `match[]={job="prometheus"}` returns labels only from info - metrics that have `job="prometheus"`. Optional. +- `expr=`: PromQL expression. If provided, the expression is evaluated + and identifying labels (job, instance) are extracted from the result to filter + which info metrics are returned. This allows filtering by complex expressions + like `rate(http_requests_total[5m])`. Optional. - `start=`: Start timestamp. Optional. - `end=`: End timestamp. Optional. - `limit=`: Maximum number of values per label. Optional. 0 means disabled. @@ -588,10 +588,10 @@ curl 'localhost:9090/api/v1/info_labels?metric_match=~.*_info' } ``` -This example filters info labels to only those from targets with `job="prometheus"`: +This example filters info labels to only those from targets matching an expression: ```bash -curl 'localhost:9090/api/v1/info_labels?match[]={job="prometheus"}' +curl 'localhost:9090/api/v1/info_labels?expr=http_requests_total{job="prometheus"}' ``` ```json @@ -604,6 +604,24 @@ curl 'localhost:9090/api/v1/info_labels?match[]={job="prometheus"}' } ``` +This example uses a complex PromQL expression to filter info labels based on +the result of a rate query: + +```bash +curl 'localhost:9090/api/v1/info_labels?expr=rate(http_requests_total[5m])' +``` + +```json +{ + "status": "success", + "data": { + "version": ["2.0", "2.1"], + "env": ["prod", "staging"], + "region": ["us-east"] + } +} +``` + ## Querying exemplars This is **experimental** and might change in the future. diff --git a/documentation/examples/info-autocomplete-demo/README.md b/documentation/examples/info-autocomplete-demo/README.md index 880f77481e..fa699e1665 100644 --- a/documentation/examples/info-autocomplete-demo/README.md +++ b/documentation/examples/info-autocomplete-demo/README.md @@ -55,11 +55,11 @@ Test the new `/api/v1/info_labels` endpoint: # Get all data labels from target_info curl 'http://localhost:9090/api/v1/info_labels' -# Filter by base metrics matching job="api-gateway" -curl 'http://localhost:9090/api/v1/info_labels?match[]={job="api-gateway"}' +# Filter by expression results +curl 'http://localhost:9090/api/v1/info_labels?expr=http_requests_total{job="api-gateway"}' # Use a different info metric -curl 'http://localhost:9090/api/v1/info_labels?metric=build_info' +curl 'http://localhost:9090/api/v1/info_labels?metric_match=build_info' ``` ## Test Data diff --git a/documentation/examples/info-autocomplete-demo/main.go b/documentation/examples/info-autocomplete-demo/main.go index 4c73be28ee..c1b8aa2a80 100644 --- a/documentation/examples/info-autocomplete-demo/main.go +++ b/documentation/examples/info-autocomplete-demo/main.go @@ -233,43 +233,60 @@ func appendTestMetrics(db *tsdb.DB) error { } // http_requests_total for api-gateway production + // Add multiple data points so rate() works for _, status := range []string{"200", "404", "500"} { for _, method := range []string{"GET", "POST"} { - _, err = app.Append(0, labels.FromStrings( + lbls := labels.FromStrings( "__name__", "http_requests_total", "job", "api-gateway", "instance", "prod-gateway-1:8080", "method", method, "status", status, - ), now, 100) - if err != nil { - return fmt.Errorf("append http_requests_total: %w", err) + ) + // Add 6 data points, 1 minute apart, with increasing values + for i := range 6 { + ts := now - int64((5-i)*60*1000) // 5 min ago to now, 1 min intervals + val := float64(100 + i*10) // Increasing counter value + _, err = app.Append(0, lbls, ts, val) + if err != nil { + return fmt.Errorf("append http_requests_total: %w", err) + } } } } // http_requests_total for api-gateway staging - _, err = app.Append(0, labels.FromStrings( + lbls := labels.FromStrings( "__name__", "http_requests_total", "job", "api-gateway", "instance", "staging-gateway-1:8080", "method", "GET", "status", "200", - ), now, 50) - if err != nil { - return fmt.Errorf("append http_requests_total: %w", err) + ) + for i := range 6 { + ts := now - int64((5-i)*60*1000) + val := float64(50 + i*5) + _, err = app.Append(0, lbls, ts, val) + if err != nil { + return fmt.Errorf("append http_requests_total: %w", err) + } } // db_queries_total for database for _, op := range []string{"SELECT", "INSERT", "UPDATE"} { - _, err = app.Append(0, labels.FromStrings( + lbls := labels.FromStrings( "__name__", "db_queries_total", "job", "database", "instance", "prod-db-1:5432", "operation", op, - ), now, 1000) - if err != nil { - return fmt.Errorf("append db_queries_total: %w", err) + ) + for i := range 6 { + ts := now - int64((5-i)*60*1000) + val := float64(1000 + i*100) + _, err = app.Append(0, lbls, ts, val) + if err != nil { + return fmt.Errorf("append db_queries_total: %w", err) + } } } @@ -297,7 +314,8 @@ func printInstructions() { fmt.Println() fmt.Println("4. Test the info_labels API endpoint:") fmt.Println(" curl 'http://localhost:9090/api/v1/info_labels'") - fmt.Println(" curl 'http://localhost:9090/api/v1/info_labels?match[]={job=\"api-gateway\"}'") + fmt.Println(" curl 'http://localhost:9090/api/v1/info_labels?expr=http_requests_total{job=\"api-gateway\"}'") + fmt.Println(" curl 'http://localhost:9090/api/v1/info_labels?expr=rate(http_requests_total[5m])'") fmt.Println() fmt.Println("Available metrics:") fmt.Println(" - http_requests_total (api-gateway in prod & staging)") diff --git a/promql/infohelper/infohelper.go b/promql/infohelper/infohelper.go index 9cb6f34ab3..93aa3ef753 100644 --- a/promql/infohelper/infohelper.go +++ b/promql/infohelper/infohelper.go @@ -76,8 +76,9 @@ func NewWithDefaults() *InfoLabelExtractor { // - ctx: Context for cancellation // - querier: Storage querier to use for fetching series // - infoMetricMatcher: Matcher for the info metric __name__ (e.g., MatchEqual "target_info" or MatchRegexp ".*_info") -// - baseMetricMatchers: If provided, only info metrics matching the same identifying labels -// as the base metrics are considered. If nil, all info metrics are returned. +// - identifyingLabelValues: If provided, only info metrics matching these identifying label values +// are considered. The map keys are identifying label names (e.g., "job", "instance") and values +// are sets of label values. If nil or empty, all info metrics are returned. // - hints: Select hints for the storage layer // // Returns a map where keys are label names and values are slices of unique values for that label. @@ -85,7 +86,7 @@ func (e *InfoLabelExtractor) ExtractDataLabels( ctx context.Context, querier storage.Querier, infoMetricMatcher *labels.Matcher, - baseMetricMatchers [][]*labels.Matcher, + identifyingLabelValues map[string]map[string]struct{}, hints *storage.SelectHints, ) (map[string][]string, annotations.Annotations, error) { var warnings annotations.Annotations @@ -93,22 +94,10 @@ func (e *InfoLabelExtractor) ExtractDataLabels( // Build matchers for the info metric query infoMatchers := []*labels.Matcher{infoMetricMatcher} - // If base metric matchers are provided, collect identifying label values from base metrics - // and filter info metrics by those values - if len(baseMetricMatchers) > 0 { - idLblValues, baseWarnings, err := e.collectIdentifyingLabelValues(ctx, querier, baseMetricMatchers, hints) - warnings.Merge(baseWarnings) - if err != nil { - return nil, warnings, err - } - - // If no identifying label values found, return empty result - if len(idLblValues) == 0 { - return map[string][]string{}, warnings, nil - } - + // If identifying label values are provided, filter info metrics by those values + if len(identifyingLabelValues) > 0 { // Add regex matchers for identifying labels - for name, vals := range idLblValues { + for name, vals := range identifyingLabelValues { infoMatchers = append(infoMatchers, labels.MustNewMatcher(labels.MatchRegexp, name, BuildRegexpAlternation(vals))) } } @@ -121,6 +110,11 @@ func (e *InfoLabelExtractor) ExtractDataLabels( dataLabels := make(map[string]map[string]struct{}) for infoSet.Next() { + // Check for context cancellation periodically + if ctx.Err() != nil { + return nil, warnings, ctx.Err() + } + series := infoSet.At() lbls := series.Labels() @@ -161,45 +155,6 @@ func (e *InfoLabelExtractor) ExtractDataLabels( return result, warnings, nil } -// collectIdentifyingLabelValues queries base metrics and extracts values for identifying labels. -func (e *InfoLabelExtractor) collectIdentifyingLabelValues( - ctx context.Context, - querier storage.Querier, - matcherSets [][]*labels.Matcher, - hints *storage.SelectHints, -) (map[string]map[string]struct{}, annotations.Annotations, error) { - idLblValues := make(map[string]map[string]struct{}) - var warnings annotations.Annotations - - for _, matchers := range matcherSets { - set := querier.Select(ctx, false, hints, matchers...) - warnings.Merge(set.Warnings()) - - for set.Next() { - series := set.At() - lbls := series.Labels() - - // Extract identifying labels - for _, idLbl := range e.config.IdentifyingLabels { - val := lbls.Get(idLbl) - if val == "" { - continue - } - if idLblValues[idLbl] == nil { - idLblValues[idLbl] = make(map[string]struct{}) - } - idLblValues[idLbl][val] = struct{}{} - } - } - - if err := set.Err(); err != nil { - return nil, warnings, err - } - } - - return idLblValues, warnings, nil -} - // IdentifyingLabels returns the identifying labels configured for this extractor. func (e *InfoLabelExtractor) IdentifyingLabels() []string { return e.config.IdentifyingLabels diff --git a/promql/infohelper/infohelper_test.go b/promql/infohelper/infohelper_test.go index 67f06005d0..f6b90acf59 100644 --- a/promql/infohelper/infohelper_test.go +++ b/promql/infohelper/infohelper_test.go @@ -40,10 +40,10 @@ func TestExtractDataLabels(t *testing.T) { t.Cleanup(func() { testStorage.Close() }) tests := []struct { - name string - infoMetricMatcher *labels.Matcher - baseMetricMatchers [][]*labels.Matcher - expectedLabels map[string][]string + name string + infoMetricMatcher *labels.Matcher + identifyingLabelValues map[string]map[string]struct{} + expectedLabels map[string][]string }{ { name: "all target_info labels without filter", @@ -57,8 +57,8 @@ func TestExtractDataLabels(t *testing.T) { { name: "filter by job=prometheus", infoMetricMatcher: labels.MustNewMatcher(labels.MatchEqual, labels.MetricName, "target_info"), - baseMetricMatchers: [][]*labels.Matcher{ - {labels.MustNewMatcher(labels.MatchEqual, "job", "prometheus")}, + identifyingLabelValues: map[string]map[string]struct{}{ + "job": {"prometheus": {}}, }, expectedLabels: map[string][]string{ "version": {"2.0", "2.1"}, @@ -68,8 +68,8 @@ func TestExtractDataLabels(t *testing.T) { { name: "filter by specific instance", infoMetricMatcher: labels.MustNewMatcher(labels.MatchEqual, labels.MetricName, "target_info"), - baseMetricMatchers: [][]*labels.Matcher{ - {labels.MustNewMatcher(labels.MatchEqual, "instance", "localhost:9090")}, + identifyingLabelValues: map[string]map[string]struct{}{ + "instance": {"localhost:9090": {}}, }, expectedLabels: map[string][]string{ "version": {"2.0"}, @@ -77,11 +77,11 @@ func TestExtractDataLabels(t *testing.T) { }, }, { - name: "filter with multiple matcher sets", + name: "filter with multiple identifying values", infoMetricMatcher: labels.MustNewMatcher(labels.MatchEqual, labels.MetricName, "target_info"), - baseMetricMatchers: [][]*labels.Matcher{ - {labels.MustNewMatcher(labels.MatchEqual, "job", "prometheus"), labels.MustNewMatcher(labels.MatchEqual, "instance", "localhost:9090")}, - {labels.MustNewMatcher(labels.MatchEqual, "job", "node")}, + identifyingLabelValues: map[string]map[string]struct{}{ + "job": {"prometheus": {}, "node": {}}, + "instance": {"localhost:9090": {}, "node1:9100": {}}, }, expectedLabels: map[string][]string{ "version": {"1.0", "2.0"}, @@ -102,10 +102,10 @@ func TestExtractDataLabels(t *testing.T) { expectedLabels: map[string][]string{}, }, { - name: "no matching base metrics", + name: "no matching identifying labels", infoMetricMatcher: labels.MustNewMatcher(labels.MatchEqual, labels.MetricName, "target_info"), - baseMetricMatchers: [][]*labels.Matcher{ - {labels.MustNewMatcher(labels.MatchEqual, "job", "nonexistent")}, + identifyingLabelValues: map[string]map[string]struct{}{ + "job": {"nonexistent": {}}, }, expectedLabels: map[string][]string{}, }, @@ -136,7 +136,7 @@ func TestExtractDataLabels(t *testing.T) { Func: "info_labels", } - result, _, err := extractor.ExtractDataLabels(ctx, q, tc.infoMetricMatcher, tc.baseMetricMatchers, hints) + result, _, err := extractor.ExtractDataLabels(ctx, q, tc.infoMetricMatcher, tc.identifyingLabelValues, hints) require.NoError(t, err) require.Equal(t, tc.expectedLabels, result) }) diff --git a/web/api/v1/api.go b/web/api/v1/api.go index 5db3846adb..fcd4fcecd4 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -935,8 +935,9 @@ func (api *API) labelValues(r *http.Request) (result apiFuncResult) { // Query parameters: // - metric_match: Matcher for the info metric name (default: "target_info"). // Supports =, =~, !=, !~ operators (e.g., "target_info", "~.*_info", "!=build_info"). -// - match[]: Optional series selectors to filter info metrics by base metrics' identifying labels. -// If provided, only info metrics with matching job/instance values are returned. +// - expr: Optional PromQL expression. If provided, the expression is evaluated and +// identifying labels (job, instance) are extracted from the result to filter info metrics. +// Note that some use cases require for this to be optional, as they want to e.g. get all labels off of target_info. // - start/end: Time range for the query (default: last 12 hours) // - limit: Maximum number of values per label to return func (api *API) infoLabels(r *http.Request) (result apiFuncResult) { @@ -973,13 +974,41 @@ func (api *API) infoLabels(r *http.Request) (result apiFuncResult) { return invalidParamError(err, "end") } - // Parse match[] for filtering by base metrics - var baseMetricMatchers [][]*labels.Matcher - if len(r.Form["match[]"]) > 0 { - baseMetricMatchers, err = parseMatchersParam(r.Form["match[]"]) + // Parse expr parameter - if provided, evaluate as PromQL and extract identifying labels + var identifyingLabelValues map[string]map[string]struct{} + var exprWarnings annotations.Annotations + if exprParam := r.FormValue("expr"); exprParam != "" { + opts, err := extractQueryOpts(r) if err != nil { return apiFuncResult{nil, &apiError{errorBadData, err}, nil, nil} } + qry, err := api.QueryEngine.NewInstantQuery(ctx, api.Queryable, opts, exprParam, end) + if err != nil { + return invalidParamError(err, "expr") + } + defer qry.Close() + + res := qry.Exec(ctx) + if res.Err != nil { + return apiFuncResult{nil, returnAPIError(res.Err), res.Warnings, nil} + } + exprWarnings = res.Warnings + + // Only Vector and Matrix results have labels to extract + switch res.Value.Type() { + case parser.ValueTypeVector, parser.ValueTypeMatrix: + // OK - these have labels + default: + return apiFuncResult{nil, &apiError{errorBadData, fmt.Errorf("expr must return series (vector or matrix), got %s", res.Value.Type())}, exprWarnings, nil} + } + + // Extract identifying labels from the result + identifyingLabelValues = extractIdentifyingLabels(res.Value, extractor.IdentifyingLabels()) + + // If no identifying labels were found, return empty result early + if len(identifyingLabelValues) == 0 { + return apiFuncResult{map[string][]string{}, nil, exprWarnings, nil} + } } // Create querier @@ -1002,7 +1031,8 @@ func (api *API) infoLabels(r *http.Request) (result apiFuncResult) { Func: "info_labels", } - dataLabels, warnings, err := extractor.ExtractDataLabels(ctx, q, infoMetricMatcher, baseMetricMatchers, hints) + dataLabels, warnings, err := extractor.ExtractDataLabels(ctx, q, infoMetricMatcher, identifyingLabelValues, hints) + warnings.Merge(exprWarnings) if err != nil { return apiFuncResult{nil, returnAPIError(err), warnings, closer} } @@ -1020,6 +1050,39 @@ func (api *API) infoLabels(r *http.Request) (result apiFuncResult) { return apiFuncResult{dataLabels, nil, warnings, closer} } +// extractIdentifyingLabels extracts identifying label values from a PromQL query result. +// It iterates through Vector or Matrix results and collects all unique values for the +// specified identifying labels (typically "job" and "instance"). +func extractIdentifyingLabels(val parser.Value, identifyingLabels []string) map[string]map[string]struct{} { + result := make(map[string]map[string]struct{}) + + switch v := val.(type) { + case promql.Vector: + for _, sample := range v { + for _, idLbl := range identifyingLabels { + if val := sample.Metric.Get(idLbl); val != "" { + if result[idLbl] == nil { + result[idLbl] = make(map[string]struct{}) + } + result[idLbl][val] = struct{}{} + } + } + } + case promql.Matrix: + for _, series := range v { + for _, idLbl := range identifyingLabels { + if val := series.Metric.Get(idLbl); val != "" { + if result[idLbl] == nil { + result[idLbl] = make(map[string]struct{}) + } + result[idLbl][val] = struct{}{} + } + } + } + } + return result +} + var ( // MinTime is the default timestamp used for the start of optional time ranges. // Exposed to let downstream projects reference it. diff --git a/web/api/v1/api_test.go b/web/api/v1/api_test.go index 0db47b1858..68d8d921e2 100644 --- a/web/api/v1/api_test.go +++ b/web/api/v1/api_test.go @@ -915,7 +915,8 @@ func TestInfoLabels(t *testing.T) { `) api := &API{ - Queryable: storage, + Queryable: storage, + QueryEngine: testEngine(t), } request := func(method string, params map[string][]string) (*http.Request, error) { @@ -954,8 +955,8 @@ func TestInfoLabels(t *testing.T) { api: api, }, { - name: "filter by job=prometheus", - params: map[string][]string{"match[]": {`{job="prometheus"}`}, "start": {"0"}, "end": {"100000"}}, + name: "filter by job=prometheus using expr", + params: map[string][]string{"expr": {`http_requests_total{job="prometheus"}`}, "start": {"0"}, "end": {"120"}}, expected: map[string][]string{ "version": {"2.0", "2.1"}, "env": {"prod", "staging"}, @@ -963,8 +964,8 @@ func TestInfoLabels(t *testing.T) { api: api, }, { - name: "filter by specific instance", - params: map[string][]string{"match[]": {`{instance="localhost:9090"}`}, "start": {"0"}, "end": {"100000"}}, + name: "filter by specific instance using expr", + params: map[string][]string{"expr": {`http_requests_total{instance="localhost:9090"}`}, "start": {"0"}, "end": {"120"}}, expected: map[string][]string{ "version": {"2.0"}, "env": {"prod"}, @@ -972,8 +973,17 @@ func TestInfoLabels(t *testing.T) { api: api, }, { - name: "multiple matchers", - params: map[string][]string{"match[]": {`{job="prometheus", instance="localhost:9090"}`, `{job="node"}`}, "start": {"0"}, "end": {"100000"}}, + name: "filter with aggregation expression", + params: map[string][]string{"expr": {`sum by (job, instance) (http_requests_total{job="prometheus", instance="localhost:9090"})`}, "start": {"0"}, "end": {"120"}}, + expected: map[string][]string{ + "version": {"2.0"}, + "env": {"prod"}, + }, + api: api, + }, + { + name: "filter with regex in expr", + params: map[string][]string{"expr": {`http_requests_total{job=~"prometheus|node", instance=~"localhost:9090|node1:9100"}`}, "start": {"0"}, "end": {"120"}}, expected: map[string][]string{ "version": {"1.0", "2.0"}, "env": {"prod"}, @@ -1028,8 +1038,8 @@ func TestInfoLabels(t *testing.T) { api: api, }, { - name: "no matching base metrics", - params: map[string][]string{"match[]": {`{job="nonexistent"}`}, "start": {"0"}, "end": {"100000"}}, + name: "no matching expr results", + params: map[string][]string{"expr": {`http_requests_total{job="nonexistent"}`}, "start": {"0"}, "end": {"120"}}, expected: map[string][]string{}, api: api, }, @@ -1044,13 +1054,26 @@ func TestInfoLabels(t *testing.T) { api: api, }, { - name: "exec error type", - params: map[string][]string{"match[]": {`{foo="boo"}`}}, + name: "exec error from queryable", + params: map[string][]string{"start": {"0"}, "end": {"100000"}}, expectedErrorType: errorExec, api: &API{ - Queryable: errorTestQueryable{err: errors.New("generic")}, + Queryable: errorTestQueryable{err: errors.New("generic")}, + QueryEngine: testEngine(t), }, }, + { + name: "invalid expr parameter", + params: map[string][]string{"expr": {`invalid{`}, "start": {"0"}, "end": {"100000"}}, + expectedErrorType: errorBadData, + api: api, + }, + { + name: "scalar expr returns error", + params: map[string][]string{"expr": {`1+1`}, "start": {"0"}, "end": {"100000"}}, + expectedErrorType: errorBadData, + api: api, + }, } { t.Run(tc.name, func(t *testing.T) { for _, method := range []string{http.MethodGet, http.MethodPost} { diff --git a/web/ui/module/codemirror-promql/src/client/prometheus.ts b/web/ui/module/codemirror-promql/src/client/prometheus.ts index f24a7da501..0e2f5b11ac 100644 --- a/web/ui/module/codemirror-promql/src/client/prometheus.ts +++ b/web/ui/module/codemirror-promql/src/client/prometheus.ts @@ -41,10 +41,10 @@ export interface PrometheusClient { flags(): Promise>; // infoLabelPairs returns data labels from info metrics (like target_info) and their values. - // If metricName is provided, only returns labels from info metrics associated with that metric - // via identifying labels (job, instance). + // If expr is provided, the expression is evaluated and identifying labels (job, instance) + // are extracted from the result to filter which info metrics are returned. // If metricMatch is provided, it specifies which info metrics to query (supports =, =~, !=, !~). - infoLabelPairs(metricName?: string, metricMatch?: string): Promise>; + infoLabelPairs(expr?: string, metricMatch?: string): Promise>; // destroy is called to release all resources held by this client destroy?(): void; @@ -209,7 +209,7 @@ export class HTTPPrometheusClient implements PrometheusClient { }); } - infoLabelPairs(metricName?: string, metricMatch?: string): Promise> { + infoLabelPairs(expr?: string, metricMatch?: string): Promise> { const params: URLSearchParams = new URLSearchParams(); if (this.lookbackInterval) { const end = new Date(); @@ -217,18 +217,26 @@ export class HTTPPrometheusClient implements PrometheusClient { params.set('start', start.toISOString()); params.set('end', end.toISOString()); } - if (metricName) { - params.set('match[]', metricName); + if (expr) { + params.set('expr', expr); } if (metricMatch) { params.set('metric_match', metricMatch); } - return this.fetchAPI>(`${this.infoLabelsEndpoint()}?${params}`).catch((error) => { - if (this.errorHandler) { - this.errorHandler(error); - } - return {}; - }); + return this.fetchAPI>(`${this.infoLabelsEndpoint()}?${params}`) + .then((data) => { + // Validate response is an object (API could return null or array on error) + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return {}; + } + return data; + }) + .catch((error) => { + if (this.errorHandler) { + this.errorHandler(error); + } + return {}; + }); } destroy(): void { @@ -509,16 +517,17 @@ export class CachedPrometheusClient implements PrometheusClient { }); } - infoLabelPairs(metricName?: string, metricMatch?: string): Promise> { + infoLabelPairs(expr?: string, metricMatch?: string): Promise> { // Info labels are expected to be relatively stable, so we cache them. - // The cache key includes the metric name and metric match. - const cacheKey = `infoLabels_${metricName || ''}_${metricMatch || ''}`; + // The cache key includes the expression and metric match. + // Use JSON.stringify to avoid collisions when parameters contain underscores. + const cacheKey = JSON.stringify(['infoLabels', expr || '', metricMatch || '']); const cached = this.cache.getInfoLabelPairs(cacheKey); - if (cached && Object.keys(cached).length > 0) { + if (cached !== undefined) { return Promise.resolve(cached); } - return this.client.infoLabelPairs(metricName, metricMatch).then((labels) => { + return this.client.infoLabelPairs(expr, metricMatch).then((labels) => { this.cache.setInfoLabelPairs(cacheKey, labels); return labels; }); diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.ts index cc761faef6..abe4f525a6 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.ts @@ -125,29 +125,19 @@ export interface Context { // extractMetricName extracts the metric name from an expression node. // It traverses the tree to find an Identifier node and returns its text. -function extractMetricName(node: SyntaxNode, state: EditorState): string { - // Try to find an Identifier node in the subtree using recursive descent - function findIdentifier(n: SyntaxNode): string { - if (n.type.id === Identifier) { - return state.sliceDoc(n.from, n.to); - } - // Check children - for (let child = n.firstChild; child; child = child.nextSibling) { - const result = findIdentifier(child); - if (result) { - return result; - } - } - return ''; - } - return findIdentifier(node); +function extractExpression(node: SyntaxNode, state: EditorState): string { + // Extract the full text of the expression node. + // This is used to pass the complete expression (e.g., "rate(http_requests_total[5m])") + // to the API for evaluating complex PromQL expressions. + return state.sliceDoc(node.from, node.to); } // InfoFunctionContext contains information about the info() function call context. interface InfoFunctionContext { // Whether we're inside the second argument of info() isInSecondArg: boolean; - // The metric expression from the first argument (e.g., "http_requests_total") + // The full expression from the first argument (e.g., "rate(http_requests_total[5m])" or "http_requests_total") + // This is passed to the API's expr parameter for evaluation. metricName: string; // The __name__ matcher from the second argument, if present (e.g., "~.*_info" for =~, "!=target_info" for !=) // Format matches the API's metric_match parameter @@ -155,7 +145,7 @@ interface InfoFunctionContext { } // getInfoFunctionContext checks if we're inside the second argument (data label matchers) -// of the info() function and extracts the metric name from the first argument. +// of the info() function and extracts the expression from the first argument. function getInfoFunctionContext(node: SyntaxNode, state: EditorState): InfoFunctionContext { const notInInfo: InfoFunctionContext = { isInSecondArg: false, metricName: '' }; @@ -178,8 +168,8 @@ function getInfoFunctionContext(node: SyntaxNode, state: EditorState): InfoFunct const firstArg = current.firstChild; if (firstArg !== null && node.from >= firstArg.to) { // Extract the metric name from the first argument - // The first arg is typically a VectorSelector with an Identifier child - const metricName = extractMetricName(firstArg, state); + // Extract the full expression from the first argument (e.g., "rate(http_requests_total[5m])") + const metricName = extractExpression(firstArg, state); // Extract __name__ matcher from the second argument (LabelMatchers) const infoMetricMatch = extractInfoMetricMatch(current, state); return { isInSecondArg: true, metricName, infoMetricMatch }; @@ -202,10 +192,16 @@ function extractInfoMetricMatch(functionCallBody: SyntaxNode, state: EditorState // e.g., info(metric, {__name__=~"target_info"}) parses as: // FunctionCallBody > VectorSelector > LabelMatchers let labelMatchersNode: SyntaxNode | null = null; - let isSecondArg = false; + let argIndex = 0; for (let child = functionCallBody.firstChild; child !== null; child = child.nextSibling) { - if (isSecondArg) { - // Second argument - look for LabelMatchers directly or inside VectorSelector + // Skip punctuation nodes (opening paren, comma, closing paren) + const nodeText = state.sliceDoc(child.from, child.to); + if (nodeText === '(' || nodeText === ')' || nodeText === ',') { + continue; + } + argIndex++; + if (argIndex >= 2) { + // Second argument or later - look for LabelMatchers directly or inside VectorSelector if (child.type.id === LabelMatchers) { labelMatchersNode = child; break; @@ -215,10 +211,6 @@ function extractInfoMetricMatch(functionCallBody: SyntaxNode, state: EditorState break; } } - // After first non-trivial child, we're in the second argument territory - if (child.type.id === VectorSelector || child.type.id === Identifier) { - isSecondArg = true; - } } if (!labelMatchersNode) { return undefined;