Skip to content

Commit c242bfa

Browse files
authored
Improve per-tenant alert generator URL template (#7458)
1 parent 8b3f7bc commit c242bfa

8 files changed

Lines changed: 63 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* [CHANGE] Cache: Setting `-blocks-storage.bucket-store.metadata-cache.bucket-index-content-ttl` to 0 will disable the bucket-index cache. #7446
66
* [CHANGE] HA Tracker: Move `-distributor.ha-tracker.failover-timeout` from a global config to a per-tenant runtime config. The flag name and default value (30s) remain the same. #7481
77
* [FEATURE] Ingester: Add experimental active series tracker that counts active series by configurable label matchers (including regex) per tenant and exposes `cortex_ingester_active_series_per_tracker` metric. Configured via `active_series_trackers` in runtime config overrides. #7476
8-
* [FEATURE] Ruler: Add per-tenant `ruler_alert_generator_url_template` runtime config option to customize alert generator URLs using Go templates. Supports Grafana Explore, Perses, and other UIs. #7302
8+
* [FEATURE] Ruler: Add per-tenant `ruler_alert_generator_url_template` runtime config option to customize alert generator URLs using Go templates. Includes a `jsonEscape` template function for safely embedding expressions in JSON-encoded URL parameters (e.g., Grafana Explore panes). Supports Grafana Explore, Perses, and other UIs. #7302
99
* [FEATURE] Distributor: Add experimental `-distributor.enable-start-timestamp` flag for Prometheus Remote Write 2.0. When enabled, `StartTimestamp (ST)` is ingested. #7371
1010
* [FEATURE] Memberlist: Add `-memberlist.cluster-label` and `-memberlist.cluster-label-verification-disabled` to prevent accidental cross-cluster gossip joins and support rolling label rollout. #7385
1111
* [FEATURE] Querier: Add timeout classification to classify query timeouts as 4XX (user error) or 5XX (system error) based on phase timing. When enabled, queries that spend most of their time in PromQL evaluation return `422 Unprocessable Entity` instead of `503 Service Unavailable`. #7374

docs/configuration/config-file-reference.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4409,8 +4409,9 @@ query_rejection:
44094409

44104410
# Go text/template for alert generator URLs. Available variables: .ExternalURL
44114411
# (resolved external URL) and .Expression (PromQL expression). Built-in
4412-
# functions like urlquery are available. If empty, uses default Prometheus
4413-
# /graph format.
4412+
# functions like urlquery are available. A jsonEscape function is also provided
4413+
# for embedding expressions inside JSON-encoded URL parameters. If empty, uses
4414+
# default Prometheus /graph format.
44144415
[ruler_alert_generator_url_template: <string> | default = ""]
44154416

44164417
# Enable to allow rules to be evaluated with data from a single zone, if other

docs/getting-started/runtime-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ overrides:
1111
tenant-a:
1212
ruler_external_url: "http://localhost:3000"
1313
ruler_alert_generator_url_template: >-
14-
{{ .ExternalURL }}/explore?schemaVersion=1&panes=%7B%22default%22:%7B%22datasource%22:%22tenant-a%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22{{ urlquery .Expression }}%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1
14+
{{ .ExternalURL }}/explore?schemaVersion=1&panes=%7B%22default%22:%7B%22datasource%22:%22tenant-a%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22{{ urlquery (jsonEscape .Expression) }}%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1
1515
1616
# Tenant using Perses for alert generator URLs.
1717
# Clicking "Source" on an alert opens Perses explore view with
1818
# the PromQL expression pre-filled and the TenantB datasource selected.
1919
tenant-b:
2020
ruler_external_url: http://localhost:8080
2121
ruler_alert_generator_url_template: >-
22-
{{ .ExternalURL }}/explore?explorer=Prometheus-PrometheusExplorer&data=%7B%22tab%22%3A%22graph%22%2C%22queries%22%3A%5B%7B%22kind%22%3A%22TimeSeriesQuery%22%2C%22spec%22%3A%7B%22plugin%22%3A%7B%22kind%22%3A%22PrometheusTimeSeriesQuery%22%2C%22spec%22%3A%7B%22datasource%22%3A%7B%22kind%22%3A%22PrometheusDatasource%22%2C%22name%22%3A%22tenantb%22%7D%2C%22query%22%3A%22{{ urlquery .Expression }}%22%7D%7D%7D%7D%5D%7D
22+
{{ .ExternalURL }}/explore?explorer=Prometheus-PrometheusExplorer&data=%7B%22tab%22%3A%22graph%22%2C%22queries%22%3A%5B%7B%22kind%22%3A%22TimeSeriesQuery%22%2C%22spec%22%3A%7B%22plugin%22%3A%7B%22kind%22%3A%22PrometheusTimeSeriesQuery%22%2C%22spec%22%3A%7B%22datasource%22%3A%7B%22kind%22%3A%22PrometheusDatasource%22%2C%22name%22%3A%22tenantb%22%7D%2C%22query%22%3A%22{{ urlquery (jsonEscape .Expression) }}%22%7D%7D%7D%7D%5D%7D
2323
2424
# Tenants without overrides use the global ruler.external.url
2525
# and the default Prometheus /graph format.

docs/getting-started/single-binary.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,22 @@ The `ruler_alert_generator_url_template` field accepts a Go template with two va
228228
- `{{ .ExternalURL }}` — the resolved external URL for this tenant (set via `ruler_external_url`)
229229
- `{{ .Expression }}` — the PromQL expression that triggered the alert
230230

231-
Built-in Go template functions like `urlquery` are available for URL encoding.
231+
Built-in Go template functions like `urlquery` are available for URL encoding. Cortex also provides a `jsonEscape` function that escapes a string for embedding inside a JSON string value (e.g., `"``\"`). Use `jsonEscape` when the expression is placed inside a JSON-encoded URL parameter, such as Grafana's `panes`.
232232

233-
Example for Grafana Explore:
233+
Example for Grafana Explore (simple query parameter):
234234
```yaml
235235
ruler_external_url: "http://localhost:3000"
236236
ruler_alert_generator_url_template: >-
237237
{{ .ExternalURL }}/explore?expr={{ urlquery .Expression }}
238238
```
239239
240+
Example for Grafana Explore (JSON-encoded `panes` parameter — use `jsonEscape` to properly escape quotes in expressions):
241+
```yaml
242+
ruler_external_url: "http://localhost:3000"
243+
ruler_alert_generator_url_template: >-
244+
{{ .ExternalURL }}/explore?schemaVersion=1&panes=%7B%22default%22:%7B%22datasource%22:%22my-datasource%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22{{ urlquery (jsonEscape .Expression) }}%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1
245+
```
246+
240247
### Try It Out
241248

242249
1. **Load alertmanager configs** for tenant-a and tenant-b:
@@ -296,6 +303,13 @@ rules:
296303
severity: critical
297304
annotations:
298305
summary: "Error rate exceeds 5%"
306+
- alert: AlwaysFiringWithQuotes
307+
expr: count(up{job!="nonexistent"} or vector(1))
308+
for: 0m
309+
labels:
310+
severity: info
311+
annotations:
312+
summary: "Demo alert with quotes in expression"
299313
EOF
300314
301315
# Alert rules for tenant-b
@@ -320,6 +334,13 @@ rules:
320334
severity: warning
321335
annotations:
322336
summary: "P99 latency exceeds 2s"
337+
- alert: AlwaysFiringWithQuotes
338+
expr: count(up{job!="nonexistent"} or vector(1))
339+
for: 0m
340+
labels:
341+
severity: info
342+
annotations:
343+
summary: "Demo alert with quotes in expression"
323344
EOF
324345
```
325346

pkg/ruler/ruler.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ruler
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"flag"
78
"fmt"
89
"hash/fnv"
@@ -539,6 +540,20 @@ type generatorURLTemplateData struct {
539540
Expression string
540541
}
541542

543+
// generatorURLTemplateFuncMap contains custom functions available in generator URL templates.
544+
// - jsonEscape: escapes a string for embedding inside a JSON string value (e.g., " → \", \ → \\).
545+
// Useful when the expression is placed inside a JSON-encoded URL parameter like Grafana's panes.
546+
var generatorURLTemplateFuncMap = template.FuncMap{
547+
"jsonEscape": func(s string) string {
548+
b, err := json.Marshal(s)
549+
if err != nil {
550+
return s
551+
}
552+
// json.Marshal wraps the string in quotes; strip them to get just the escaped content.
553+
return string(b[1 : len(b)-1])
554+
},
555+
}
556+
542557
// generatorURLTemplateCache caches a parsed text/template keyed on the template string.
543558
// If the template string changes (e.g., via runtime config), the cache is invalidated.
544559
type generatorURLTemplateCache struct {
@@ -552,7 +567,7 @@ func (c *generatorURLTemplateCache) getOrParse(tmplStr string) (*template.Templa
552567
if c.tmpl != nil && c.tmplStr == tmplStr {
553568
return c.tmpl, nil
554569
}
555-
tmpl, err := template.New("generator_url").Parse(tmplStr)
570+
tmpl, err := template.New("generator_url").Funcs(generatorURLTemplateFuncMap).Parse(tmplStr)
556571
if err != nil {
557572
return nil, err
558573
}

pkg/ruler/ruler_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2833,12 +2833,19 @@ func TestExecuteGeneratorURLTemplate(t *testing.T) {
28332833
expectErr: true,
28342834
},
28352835
{
2836-
name: "template with multiple variables",
2837-
tmplStr: "{{ .ExternalURL }}/explore?left=%7B%22queries%22:%5B%7B%22expr%22:%22{{ urlquery .Expression }}%22%7D%5D%7D",
2836+
name: "template with JSON-encoded panes parameter",
2837+
tmplStr: "{{ .ExternalURL }}/explore?left=%7B%22queries%22:%5B%7B%22expr%22:%22{{ urlquery (jsonEscape .Expression) }}%22%7D%5D%7D",
28382838
externalURL: "http://grafana:3000",
28392839
expr: "up",
28402840
expected: "http://grafana:3000/explore?left=%7B%22queries%22:%5B%7B%22expr%22:%22up%22%7D%5D%7D",
28412841
},
2842+
{
2843+
name: "grafana explore template with expression containing double quotes",
2844+
tmplStr: `{{ .ExternalURL }}/explore?schemaVersion=1&panes=%7B%22default%22:%7B%22datasource%22:%22tenant-a%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22{{ urlquery (jsonEscape .Expression) }}%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1`,
2845+
externalURL: "http://localhost:3000",
2846+
expr: `count(up{job!="nonexistent"} or vector(1))`,
2847+
expected: `http://localhost:3000/explore?schemaVersion=1&panes=%7B%22default%22:%7B%22datasource%22:%22tenant-a%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22count%28up%7Bjob%21%3D%5C%22nonexistent%5C%22%7D+or+vector%281%29%29%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1`,
2848+
},
28422849
{
28432850
name: "javascript URI scheme is rejected",
28442851
tmplStr: "javascript://alert('xss')",

pkg/util/validation/limits.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ type Limits struct {
227227
RulerQueryOffset model.Duration `yaml:"ruler_query_offset" json:"ruler_query_offset"`
228228
RulerExternalLabels labels.Labels `yaml:"ruler_external_labels" json:"ruler_external_labels" doc:"nocli|description=external labels for alerting rules"`
229229
RulerExternalURL string `yaml:"ruler_external_url" json:"ruler_external_url" doc:"nocli|description=Per-tenant external URL for the ruler. If set, it overrides the global -ruler.external.url for this tenant's alert notifications."`
230-
RulerAlertGeneratorURLTemplate string `yaml:"ruler_alert_generator_url_template" json:"ruler_alert_generator_url_template" doc:"nocli|description=Go text/template for alert generator URLs. Available variables: .ExternalURL (resolved external URL) and .Expression (PromQL expression). Built-in functions like urlquery are available. If empty, uses default Prometheus /graph format."`
230+
RulerAlertGeneratorURLTemplate string `yaml:"ruler_alert_generator_url_template" json:"ruler_alert_generator_url_template" doc:"nocli|description=Go text/template for alert generator URLs. Available variables: .ExternalURL (resolved external URL) and .Expression (PromQL expression). Built-in functions like urlquery are available. A jsonEscape function is also provided for embedding expressions inside JSON-encoded URL parameters. If empty, uses default Prometheus /graph format."`
231231
RulesPartialData bool `yaml:"rules_partial_data" json:"rules_partial_data" doc:"nocli|description=Enable to allow rules to be evaluated with data from a single zone, if other zones are not available.|default=false"`
232232

233233
// Store-gateway.
@@ -443,7 +443,13 @@ func (l *Limits) Validate(nameValidationScheme model.ValidationScheme, shardByAl
443443
}
444444

445445
if l.RulerAlertGeneratorURLTemplate != "" {
446-
if _, err := template.New("").Parse(l.RulerAlertGeneratorURLTemplate); err != nil {
446+
// Register custom functions so that templates using them pass validation.
447+
// The actual implementations are in the ruler package; these stubs just
448+
// allow the parser to accept the function names.
449+
funcMap := template.FuncMap{
450+
"jsonEscape": func(s string) string { return s },
451+
}
452+
if _, err := template.New("").Funcs(funcMap).Parse(l.RulerAlertGeneratorURLTemplate); err != nil {
447453
return fmt.Errorf("invalid ruler_alert_generator_url_template: %w", err)
448454
}
449455
}

schemas/cortex-config-schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5548,7 +5548,7 @@
55485548
"x-format": "duration"
55495549
},
55505550
"ruler_alert_generator_url_template": {
5551-
"description": "Go text/template for alert generator URLs. Available variables: .ExternalURL (resolved external URL) and .Expression (PromQL expression). Built-in functions like urlquery are available. If empty, uses default Prometheus /graph format.",
5551+
"description": "Go text/template for alert generator URLs. Available variables: .ExternalURL (resolved external URL) and .Expression (PromQL expression). Built-in functions like urlquery are available. A jsonEscape function is also provided for embedding expressions inside JSON-encoded URL parameters. If empty, uses default Prometheus /graph format.",
55525552
"type": "string"
55535553
},
55545554
"ruler_evaluation_delay_duration": {

0 commit comments

Comments
 (0)