diff --git a/README.md b/README.md index 7a2feb7..4f6c989 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,17 @@ annotations: description: "Generated by: {{ .Expr }}" ``` +Use `humanizeDuration` to format a value in seconds. For example, a query value of +`4800` renders as `1h 20m 0s`: + +```yaml +annotations: + summary: "{{ $labels.service }}: last reading was {{ $value | humanizeDuration }} ago" +``` + +The helper also accepts `.Value`. Formatting changes only the annotation; the +numeric value remains in seconds. + ### Environment Variable Substitution Use `%{ENV_VAR}` syntax in rule files (same as vmalert): diff --git a/go.mod b/go.mod index 58c2218..6bc0666 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( github.com/ClickHouse/clickhouse-go/v2 v2.43.0 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.66.1 github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go/modules/clickhouse v0.40.0 gopkg.in/yaml.v3 v3.0.1 @@ -58,7 +59,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect diff --git a/rule/rule.go b/rule/rule.go index deb0d25..fb773fe 100644 --- a/rule/rule.go +++ b/rule/rule.go @@ -38,6 +38,8 @@ import ( "text/template" "time" + "github.com/prometheus/common/helpers/templates" + "github.com/garbett1/chalert/config" "github.com/garbett1/chalert/datasource" ) @@ -432,7 +434,9 @@ func (ar *AlertingRule) renderAnnotations(m datasource.Metric, _ *AlertInstance) // Rewrite legacy {{ $labels.X }} and {{ $value }} to Go template syntax. normalized := normalizeLegacyTemplate(tpl) - t, err := template.New(k).Option("missingkey=zero").Parse(normalized) + t, err := template.New(k).Funcs(template.FuncMap{ + "humanizeDuration": templates.HumanizeDuration, + }).Option("missingkey=zero").Parse(normalized) if err != nil { // Fall back to raw template on parse error. slog.Warn("chalert annotation template parse error", diff --git a/rule/rule_test.go b/rule/rule_test.go index c5f744a..228ba15 100644 --- a/rule/rule_test.go +++ b/rule/rule_test.go @@ -552,3 +552,64 @@ func TestAnnotationTemplates_FullGoTemplate(t *testing.T) { } } } + +func TestAlertingRule_HumanizeDuration(t *testing.T) { + q := &fakeQuerier{ + results: datasource.Result{ + Data: []datasource.Metric{{ + Labels: []datasource.Label{ + {Name: "pod_site", Value: "buf101"}, + {Name: "pod", Value: "pod1"}, + }, + Values: []float64{0}, + }}, + }, + } + r := NewAlertingRule(&fakeQuerierBuilder{q: q}, "g", time.Minute, config.Rule{ + Alert: "TPUCCPodDataStale", + Expr: "SELECT 'buf101' AS pod_site, 'pod1' AS pod, 1200 AS value", + Annotations: map[string]string{ + "summary": "{{ $labels.pod_site }} {{ $labels.pod }}: last inlet reading was {{ $value | humanizeDuration }} ago", + "native": "{{ .Labels.pod_site }} {{ .Labels.pod }}: last inlet reading was {{ humanizeDuration .Value }} ago", + }, + ID: 778, + }) + + tests := []struct { + name string + seconds float64 + want string + }{ + {name: "minutes", seconds: 1200, want: "20m 0s"}, + {name: "hours", seconds: 4800, want: "1h 20m 0s"}, + {name: "days", seconds: 93600, want: "1d 2h 0m 0s"}, + } + var alertID uint64 + now := time.Now() + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q.results.Data[0].Values[0] = tt.seconds + alerts, err := r.Exec(context.Background(), now.Add(time.Duration(i)*time.Minute), 0) + if err != nil { + t.Fatal(err) + } + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + want := "buf101 pod1: last inlet reading was " + tt.want + " ago" + for _, key := range []string{"summary", "native"} { + if got := alerts[0].Annotations[key]; got != want { + t.Errorf("annotation %q: want %q, got %q", key, want, got) + } + } + if got := alerts[0].Value; got != tt.seconds { + t.Errorf("formatting changed value: want %v seconds, got %v", tt.seconds, got) + } + if i == 0 { + alertID = alerts[0].ID + } else if alerts[0].ID != alertID { + t.Errorf("duration change created a new alert: want ID %d, got %d", alertID, alerts[0].ID) + } + }) + } +}