From 562cd9e44e795f51f44f54cc01b9ba9d8078a6f3 Mon Sep 17 00:00:00 2001
From: Santiago <santiagohernandez.1997@gmail.com>
Date: Mon, 4 Oct 2021 15:04:37 -0300
Subject: [PATCH] Alerting template functions (#39261)

* Alerting: (wip) add template funcs

* Alerting: (wip) numeric template functions

* Alerting: (wip) template functions

* Test for the "args" function

* Alerting: (wip) Documentation for template functions

* Alerting: template functions - refactor

* code review changes

* disable linter error

* Use Prometheus implementation of TemplateExpander

* Update docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* change templateCaptureValue to support using template functions

* Update pkg/services/ngalert/state/template.go

Co-authored-by: gotjosh <josue.abreu@gmail.com>

* Test and documentation added for reReplaceAll template function

* complete missing functions, documentation and tests

* Use the alert instance's evaluation time for expanding the template

* strvalue graphlink and tablelink functions

* delete duplicate test

* make strvalue return an empty string

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: gotjosh <josue.abreu@gmail.com>
---
 .../create-grafana-managed-rule.md            |  30 ++
 pkg/services/ngalert/ngalert.go               |   2 +-
 .../ngalert/schedule/schedule_test.go         |   4 +-
 .../ngalert/schedule/schedule_unit_test.go    |   2 +-
 pkg/services/ngalert/state/cache.go           |  89 +---
 pkg/services/ngalert/state/cache_test.go      | 126 ------
 pkg/services/ngalert/state/manager.go         |   5 +-
 pkg/services/ngalert/state/manager_test.go    |   4 +-
 pkg/services/ngalert/state/template.go        |  89 ++++
 pkg/services/ngalert/state/template_test.go   | 400 ++++++++++++++++++
 10 files changed, 540 insertions(+), 211 deletions(-)
 delete mode 100644 pkg/services/ngalert/state/cache_test.go
 create mode 100644 pkg/services/ngalert/state/template.go
 create mode 100644 pkg/services/ngalert/state/template_test.go

diff --git a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md
index 55a7c5a71c9..13c1401168c 100644
--- a/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md
+++ b/docs/sources/alerting/unified-alerting/alerting-rules/create-grafana-managed-rule.md
@@ -112,6 +112,36 @@ The following template variables are available when expanding annotations and la
 | $values | The values of all reduce and math expressions that were evaluated for this alert rule. For example, `{{ $values.A }}`, `{{ $values.A.Labels }}` and `{{ $values.A.Value }}` where `A` is the `refID` of the expression. This is unavailable when the rule uses a classic condition. |
 | $value  | The value string of the alert instance. For example, `[ var='A' labels={instance=foo} value=10 ]`.                                                                                                                                                                                  |
 
+#### Template functions
+
+The following template functions are available when expanding annotations and labels.
+
+| Name               | Argument                   | Return                 | Description                                                                                                                        |
+| ------------------ | -------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| humanize           | number or string           | string                 | Converts a number to a more readable format, using metric prefixes.                                                                |
+| humanize1024       | number or string           | string                 | Like humanize, but uses 1024 as the base rather than 1000.                                                                         |
+| humanizeDuration   | number or string           | string                 | Converts a duration in seconds to a more readable format.                                                                          |
+| humanizePercentage | number or string           | string                 | Converts a ratio value to a fraction of 100.                                                                                       |
+| humanizeTimestamp  | number or string           | string                 | Converts a Unix timestamp in seconds to a more readable format.                                                                    |
+| title              | string                     | string                 | strings.Title, capitalises first character of each word.                                                                           |
+| toUpper            | string                     | string                 | strings.ToUpper, converts all characters to upper case.                                                                            |
+| toLower            | string                     | string                 | strings.ToLower, converts all characters to lower case.                                                                            |
+| match              | pattern, text              | boolean                | regexp.MatchString Tests for a unanchored regexp match.                                                                            |
+| reReplaceAll       | pattern, replacement, text | string                 | Regexp.ReplaceAllString Regexp substitution, unanchored.                                                                           |
+| graphLink          | expr                       | string                 | Not supported                                                                                                                      |
+| tableLink          | expr                       | string                 | Not supported                                                                                                                      |
+| args               | []interface{}              | map[string]interface{} | Converts a list of objects to a map with keys, for example, arg0, arg1. Use this function to pass multiple arguments to templates. |
+| externalURL        | nothing                    | string                 | Returns a string representing the external URL.                                                                                    |
+| pathPrefix         | nothing                    | string                 | Returns the path of the external URL.                                                                                              |
+| tmpl               | string, []interface{}      | nothing                | Not supported.                                                                                                                     |
+| safeHtml           | string                     | string                 | Not supported.                                                                                                                     |
+| query              | query string               | []sample               | Not supported.                                                                                                                     |
+| first              | []sample                   | sample                 | Not supported.                                                                                                                     |
+| label              | label, sample              | string                 | Not supported.                                                                                                                     |
+| strvalue           | []sample                   | string                 | Not supported.                                                                                                                     |
+| value              | sample                     | float64                | Not supported.                                                                                                                     |
+| sortByLabel        | label, []samples           | []sample               | Not supported.                                                                                                                     |
+
 ## Preview alerts
 
 To evaluate the rule and see what alerts it would produce, click **Preview alerts**. It will display a list of alerts with state and value for each one.
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index e4a7ac2e51e..dc8e146fbde 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -132,7 +132,7 @@ func (ng *AlertNG) init() error {
 		ng.Log.Error("Failed to parse application URL. Continue without it.", "error", err)
 		appUrl = nil
 	}
-	stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), store, store)
+	stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), appUrl, store, store)
 	scheduler := schedule.NewScheduler(schedCfg, ng.DataService, appUrl, stateManager)
 
 	ng.stateManager = stateManager
diff --git a/pkg/services/ngalert/schedule/schedule_test.go b/pkg/services/ngalert/schedule/schedule_test.go
index 60b1c809a2c..c707f9ea540 100644
--- a/pkg/services/ngalert/schedule/schedule_test.go
+++ b/pkg/services/ngalert/schedule/schedule_test.go
@@ -104,7 +104,7 @@ func TestWarmStateCache(t *testing.T) {
 		Metrics:                 testMetrics.GetSchedulerMetrics(),
 		AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
 	}
-	st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), dbstore, dbstore)
+	st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore)
 	st.Warm()
 
 	t.Run("instance cache has expected entries", func(t *testing.T) {
@@ -155,7 +155,7 @@ func TestAlertingTicker(t *testing.T) {
 			disabledOrgID: {},
 		},
 	}
-	st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), dbstore, dbstore)
+	st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore)
 	appUrl := &url.URL{
 		Scheme: "http",
 		Host:   "localhost",
diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go
index d81eff1c2e0..ac3552eba60 100644
--- a/pkg/services/ngalert/schedule/schedule_unit_test.go
+++ b/pkg/services/ngalert/schedule/schedule_unit_test.go
@@ -247,7 +247,7 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac
 		Metrics:                 m.GetSchedulerMetrics(),
 		AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
 	}
-	st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), rs, is)
+	st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), nil, rs, is)
 	appUrl := &url.URL{
 		Scheme: "http",
 		Host:   "localhost",
diff --git a/pkg/services/ngalert/state/cache.go b/pkg/services/ngalert/state/cache.go
index ff605951a76..31366e11880 100644
--- a/pkg/services/ngalert/state/cache.go
+++ b/pkg/services/ngalert/state/cache.go
@@ -1,13 +1,10 @@
 package state
 
 import (
-	"bytes"
 	"fmt"
-	"math"
-	"strconv"
+	"net/url"
 	"strings"
 	"sync"
-	text_template "text/template"
 
 	"github.com/grafana/grafana-plugin-sdk-go/data"
 
@@ -19,17 +16,19 @@ import (
 )
 
 type cache struct {
-	states    map[int64]map[string]map[string]*State // orgID > alertRuleUID > stateID > state
-	mtxStates sync.RWMutex
-	log       log.Logger
-	metrics   *metrics.State
+	states      map[int64]map[string]map[string]*State // orgID > alertRuleUID > stateID > state
+	mtxStates   sync.RWMutex
+	log         log.Logger
+	metrics     *metrics.State
+	externalURL *url.URL
 }
 
-func newCache(logger log.Logger, metrics *metrics.State) *cache {
+func newCache(logger log.Logger, metrics *metrics.State, externalURL *url.URL) *cache {
 	return &cache{
-		states:  make(map[int64]map[string]map[string]*State),
-		log:     logger,
-		metrics: metrics,
+		states:      make(map[int64]map[string]map[string]*State),
+		log:         logger,
+		metrics:     metrics,
+		externalURL: externalURL,
 	}
 }
 
@@ -93,7 +92,7 @@ func (c *cache) expandRuleLabelsAndAnnotations(alertRule *ngModels.AlertRule, la
 	expand := func(original map[string]string) map[string]string {
 		expanded := make(map[string]string, len(original))
 		for k, v := range original {
-			ev, err := expandTemplate(alertRule.Title, v, labels, alertInstance)
+			ev, err := expandTemplate(alertRule.Title, v, labels, alertInstance, c.externalURL)
 			expanded[k] = ev
 			if err != nil {
 				c.log.Error("error in expanding template", "name", k, "value", v, "err", err.Error())
@@ -107,70 +106,6 @@ func (c *cache) expandRuleLabelsAndAnnotations(alertRule *ngModels.AlertRule, la
 	return expand(alertRule.Labels), expand(alertRule.Annotations)
 }
 
-// templateCaptureValue represents each value in .Values in the annotations
-// and labels template.
-type templateCaptureValue struct {
-	Labels map[string]string
-	Value  float64
-}
-
-// String implements the Stringer interface to print the value of each RefID
-// in the template via {{ $values.A }} rather than {{ $values.A.Value }}.
-func (v templateCaptureValue) String() string {
-	return strconv.FormatFloat(v.Value, 'f', -1, 64)
-}
-
-func expandTemplate(name, text string, labels map[string]string, alertInstance eval.Result) (result string, resultErr error) {
-	name = "__alert_" + name
-	text = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + text
-	// It'd better to have no alert description than to kill the whole process
-	// if there's a bug in the template.
-	defer func() {
-		if r := recover(); r != nil {
-			var ok bool
-			resultErr, ok = r.(error)
-			if !ok {
-				resultErr = fmt.Errorf("panic expanding template %v: %v", name, r)
-			}
-		}
-	}()
-
-	tmpl, err := text_template.New(name).Option("missingkey=error").Parse(text)
-	if err != nil {
-		return "", fmt.Errorf("error parsing template %v: %s", name, err.Error())
-	}
-	var buffer bytes.Buffer
-	if err := tmpl.Execute(&buffer, struct {
-		Labels map[string]string
-		Values map[string]templateCaptureValue
-		Value  string
-	}{
-		Labels: labels,
-		Values: newTemplateCaptureValues(alertInstance.Values),
-		Value:  alertInstance.EvaluationString,
-	}); err != nil {
-		return "", fmt.Errorf("error executing template %v: %s", name, err.Error())
-	}
-	return buffer.String(), nil
-}
-
-func newTemplateCaptureValues(values map[string]eval.NumberValueCapture) map[string]templateCaptureValue {
-	m := make(map[string]templateCaptureValue)
-	for k, v := range values {
-		var f float64
-		if v.Value != nil {
-			f = *v.Value
-		} else {
-			f = math.NaN()
-		}
-		m[k] = templateCaptureValue{
-			Labels: v.Labels,
-			Value:  f,
-		}
-	}
-	return m
-}
-
 func (c *cache) set(entry *State) {
 	c.mtxStates.Lock()
 	defer c.mtxStates.Unlock()
diff --git a/pkg/services/ngalert/state/cache_test.go b/pkg/services/ngalert/state/cache_test.go
deleted file mode 100644
index 9f553105ced..00000000000
--- a/pkg/services/ngalert/state/cache_test.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package state
-
-import (
-	"errors"
-	"testing"
-
-	"github.com/grafana/grafana-plugin-sdk-go/data"
-	"github.com/grafana/grafana/pkg/services/ngalert/eval"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	ptr "github.com/xorcare/pointer"
-)
-
-func TestTemplateCaptureValueStringer(t *testing.T) {
-	cases := []struct {
-		name     string
-		value    templateCaptureValue
-		expected string
-	}{{
-		name:     "0 is returned as integer value",
-		value:    templateCaptureValue{Value: 0},
-		expected: "0",
-	}, {
-		name:     "1.0 is returned as integer value",
-		value:    templateCaptureValue{Value: 1.0},
-		expected: "1",
-	}, {
-		name:     "1.1 is returned as decimal value",
-		value:    templateCaptureValue{Value: 1.1},
-		expected: "1.1",
-	}}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			assert.Equal(t, c.expected, c.value.String())
-		})
-	}
-}
-
-func TestExpandTemplate(t *testing.T) {
-	cases := []struct {
-		name          string
-		text          string
-		alertInstance eval.Result
-		labels        data.Labels
-		expected      string
-		expectedError error
-	}{{
-		name:     "labels are expanded into $labels",
-		text:     "{{ $labels.instance }} is down",
-		labels:   data.Labels{"instance": "foo"},
-		expected: "foo is down",
-	}, {
-		name:          "missing label in $labels returns error",
-		text:          "{{ $labels.instance }} is down",
-		labels:        data.Labels{},
-		expectedError: errors.New("error executing template __alert_test: template: __alert_test:1:86: executing \"__alert_test\" at <$labels.instance>: map has no entry for key \"instance\""),
-	}, {
-		name: "values are expanded into $values",
-		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
-		alertInstance: eval.Result{
-			Values: map[string]eval.NumberValueCapture{
-				"A": {
-					Var:    "A",
-					Labels: data.Labels{"instance": "foo"},
-					Value:  ptr.Float64(1),
-				},
-			},
-		},
-		expected: "foo has value 1",
-	}, {
-		name: "values can be passed to template functions such as printf",
-		text: "{{ $values.A.Labels.instance }} has value {{ $values.A.Value | printf \"%.1f\" }}",
-		alertInstance: eval.Result{
-			Values: map[string]eval.NumberValueCapture{
-				"A": {
-					Var:    "A",
-					Labels: data.Labels{"instance": "foo"},
-					Value:  ptr.Float64(1.1),
-				},
-			},
-		},
-		expected: "foo has value 1.1",
-	}, {
-		name: "missing label in $values returns error",
-		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
-		alertInstance: eval.Result{
-			Values: map[string]eval.NumberValueCapture{
-				"A": {
-					Var:    "A",
-					Labels: data.Labels{},
-					Value:  ptr.Float64(1),
-				},
-			},
-		},
-		expectedError: errors.New("error executing template __alert_test: template: __alert_test:1:86: executing \"__alert_test\" at <$values.A.Labels.instance>: map has no entry for key \"instance\""),
-	}, {
-		name: "missing value in $values is returned as NaN",
-		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
-		alertInstance: eval.Result{
-			Values: map[string]eval.NumberValueCapture{
-				"A": {
-					Var:    "A",
-					Labels: data.Labels{"instance": "foo"},
-					Value:  nil,
-				},
-			},
-		},
-		expected: "foo has value NaN",
-	}, {
-		name: "assert value string is expanded into $value",
-		text: "{{ $value }}",
-		alertInstance: eval.Result{
-			EvaluationString: "[ var='A' labels={instance=foo} value=10 ]",
-		},
-		expected: "[ var='A' labels={instance=foo} value=10 ]",
-	}}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			v, err := expandTemplate("test", c.text, c.labels, c.alertInstance)
-			require.Equal(t, c.expectedError, err)
-			require.Equal(t, c.expected, v)
-		})
-	}
-}
diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go
index a92bd2b5ced..ecd4030e116 100644
--- a/pkg/services/ngalert/state/manager.go
+++ b/pkg/services/ngalert/state/manager.go
@@ -3,6 +3,7 @@ package state
 import (
 	"context"
 	"fmt"
+	"net/url"
 	"strconv"
 	"time"
 
@@ -32,9 +33,9 @@ type Manager struct {
 	instanceStore store.InstanceStore
 }
 
-func NewManager(logger log.Logger, metrics *metrics.State, ruleStore store.RuleStore, instanceStore store.InstanceStore) *Manager {
+func NewManager(logger log.Logger, metrics *metrics.State, externalURL *url.URL, ruleStore store.RuleStore, instanceStore store.InstanceStore) *Manager {
 	manager := &Manager{
-		cache:         newCache(logger, metrics),
+		cache:         newCache(logger, metrics, externalURL),
 		quit:          make(chan struct{}),
 		ResendDelay:   ResendDelay, // TODO: make this configurable
 		log:           logger,
diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go
index 0cf8dc587b6..2241ea01b6b 100644
--- a/pkg/services/ngalert/state/manager_test.go
+++ b/pkg/services/ngalert/state/manager_test.go
@@ -851,7 +851,7 @@ func TestProcessEvalResults(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil)
+		st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, nil)
 		t.Run(tc.desc, func(t *testing.T) {
 			for _, res := range tc.evalResults {
 				_ = st.ProcessEvalResults(tc.alertRule, res)
@@ -947,7 +947,7 @@ func TestStaleResultsHandler(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), dbstore, dbstore)
+		st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore)
 		st.Warm()
 		existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
 
diff --git a/pkg/services/ngalert/state/template.go b/pkg/services/ngalert/state/template.go
new file mode 100644
index 00000000000..81d66760f9e
--- /dev/null
+++ b/pkg/services/ngalert/state/template.go
@@ -0,0 +1,89 @@
+package state
+
+import (
+	"context"
+	"math"
+	"net/url"
+	"strconv"
+	"time"
+
+	text_template "text/template"
+
+	"github.com/grafana/grafana/pkg/services/ngalert/eval"
+	"github.com/prometheus/common/model"
+	"github.com/prometheus/prometheus/pkg/timestamp"
+	"github.com/prometheus/prometheus/promql"
+	"github.com/prometheus/prometheus/template"
+)
+
+// templateCaptureValue represents each value in .Values in the annotations
+// and labels template.
+type templateCaptureValue struct {
+	Labels map[string]string
+	Value  float64
+}
+
+// String implements the Stringer interface to print the value of each RefID
+// in the template via {{ $values.A }} rather than {{ $values.A.Value }}.
+func (v templateCaptureValue) String() string {
+	return strconv.FormatFloat(v.Value, 'f', -1, 64)
+}
+
+func expandTemplate(name, text string, labels map[string]string, alertInstance eval.Result, externalURL *url.URL) (result string, resultErr error) {
+	name = "__alert_" + name
+	text = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + text
+	data := struct {
+		Labels map[string]string
+		Values map[string]templateCaptureValue
+		Value  string
+	}{
+		Labels: labels,
+		Values: newTemplateCaptureValues(alertInstance.Values),
+		Value:  alertInstance.EvaluationString,
+	}
+
+	expander := template.NewTemplateExpander(
+		context.TODO(), // This context is only used with the `query()` function - which we don't support yet.
+		text,
+		name,
+		data,
+		model.Time(timestamp.FromTime(alertInstance.EvaluatedAt)),
+		func(context.Context, string, time.Time) (promql.Vector, error) {
+			return nil, nil
+		},
+		externalURL,
+		[]string{"missingkey=error"},
+	)
+
+	expander.Funcs(text_template.FuncMap{
+		// These three functions are no-ops for now.
+		"strvalue": func(value templateCaptureValue) string {
+			return ""
+		},
+		"graphLink": func() string {
+			return ""
+		},
+		"tableLink": func() string {
+			return ""
+		},
+	})
+
+	return expander.Expand()
+}
+
+func newTemplateCaptureValues(values map[string]eval.NumberValueCapture) map[string]templateCaptureValue {
+	m := make(map[string]templateCaptureValue)
+	for k, v := range values {
+		var f float64
+		if v.Value != nil {
+			f = *v.Value
+		} else {
+			f = math.NaN()
+		}
+		m[k] = templateCaptureValue{
+			Labels: v.Labels,
+			Value:  f,
+		}
+	}
+	return m
+}
diff --git a/pkg/services/ngalert/state/template_test.go b/pkg/services/ngalert/state/template_test.go
new file mode 100644
index 00000000000..9544ec4c892
--- /dev/null
+++ b/pkg/services/ngalert/state/template_test.go
@@ -0,0 +1,400 @@
+package state
+
+import (
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/grafana/grafana-plugin-sdk-go/data"
+	"github.com/grafana/grafana/pkg/services/ngalert/eval"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	ptr "github.com/xorcare/pointer"
+)
+
+func TestTemplateCaptureValueStringer(t *testing.T) {
+	cases := []struct {
+		name     string
+		value    templateCaptureValue
+		expected string
+	}{{
+		name:     "0 is returned as integer value",
+		value:    templateCaptureValue{Value: 0},
+		expected: "0",
+	}, {
+		name:     "1.0 is returned as integer value",
+		value:    templateCaptureValue{Value: 1.0},
+		expected: "1",
+	}, {
+		name:     "1.1 is returned as decimal value",
+		value:    templateCaptureValue{Value: 1.1},
+		expected: "1.1",
+	}}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			assert.Equal(t, c.expected, c.value.String())
+		})
+	}
+}
+
+func TestExpandTemplate(t *testing.T) {
+	pathPrefix := "/path/prefix"
+	externalURL, err := url.Parse("http://localhost" + pathPrefix)
+	assert.NoError(t, err)
+
+	cases := []struct {
+		name          string
+		text          string
+		alertInstance eval.Result
+		labels        data.Labels
+		expected      string
+		expectedError error
+	}{{
+		name:     "labels are expanded into $labels",
+		text:     "{{ $labels.instance }} is down",
+		labels:   data.Labels{"instance": "foo"},
+		expected: "foo is down",
+	}, {
+		name:          "missing label in $labels returns error",
+		text:          "{{ $labels.instance }} is down",
+		labels:        data.Labels{},
+		expectedError: errors.New("error executing template __alert_test: template: __alert_test:1:86: executing \"__alert_test\" at <$labels.instance>: map has no entry for key \"instance\""),
+	}, {
+		name: "values are expanded into $values",
+		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{"instance": "foo"},
+					Value:  ptr.Float64(1),
+				},
+			},
+		},
+		expected: "foo has value 1",
+	}, {
+		name: "values can be passed to template functions such as printf",
+		text: "{{ $values.A.Labels.instance }} has value {{ $values.A.Value | printf \"%.1f\" }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{"instance": "foo"},
+					Value:  ptr.Float64(1.1),
+				},
+			},
+		},
+		expected: "foo has value 1.1",
+	}, {
+		name: "missing label in $values returns error",
+		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(1),
+				},
+			},
+		},
+		expectedError: errors.New("error executing template __alert_test: template: __alert_test:1:86: executing \"__alert_test\" at <$values.A.Labels.instance>: map has no entry for key \"instance\""),
+	}, {
+		name: "missing value in $values is returned as NaN",
+		text: "{{ $values.A.Labels.instance }} has value {{ $values.A }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{"instance": "foo"},
+					Value:  nil,
+				},
+			},
+		},
+		expected: "foo has value NaN",
+	}, {
+		name: "assert value string is expanded into $value",
+		text: "{{ $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "[ var='A' labels={instance=foo} value=10 ]",
+		},
+		expected: "[ var='A' labels={instance=foo} value=10 ]",
+	}, {
+		name: "float64 is humanized correctly",
+		text: "{{ humanize $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "1234567.0",
+		},
+		expected: "1.235M",
+	}, {
+		name: "int is humanized correctly",
+		text: "{{ humanize $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "1234567",
+		},
+		expected: "1.235M",
+	}, {
+		name: "humanize string with error",
+		text: `{{ humanize $value }}`,
+		alertInstance: eval.Result{
+			EvaluationString: "invalid",
+		},
+		expectedError: errors.New(`error executing template __alert_test: template: __alert_test:1:79: executing "__alert_test" at <humanize $value>: error calling humanize: strconv.ParseFloat: parsing "invalid": invalid syntax`),
+	}, {
+		name: "humanize1024 float64",
+		text: "{{ range $key, $val := $values }}{{ humanize1024 .Value }}:{{ end }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(0.0),
+				},
+				"B": {
+					Var:    "B",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(1.0),
+				},
+				"C": {
+					Var:    "C",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(1048576.0),
+				},
+				"D": {
+					Var:    "D",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(.12),
+				},
+			},
+		},
+		expected: "0:1:1Mi:0.12:",
+	}, {
+		name: "humanize1024 string with error",
+		text: "{{ humanize1024 $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "invalid",
+		},
+		expectedError: errors.New(`error executing template __alert_test: template: __alert_test:1:79: executing "__alert_test" at <humanize1024 $value>: error calling humanize1024: strconv.ParseFloat: parsing "invalid": invalid syntax`),
+	}, {
+		name: "humanizeDuration - seconds - float64",
+		text: "{{ range $key, $val := $values }}{{ humanizeDuration .Value }}:{{ end }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(0),
+				},
+				"B": {
+					Var:    "B",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(1),
+				},
+				"C": {
+					Var:    "C",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(60),
+				},
+				"D": {
+					Var:    "D",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(3600),
+				},
+				"E": {
+					Var:    "E",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(86400),
+				},
+				"F": {
+					Var:    "F",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(86400 + 3600),
+				},
+				"G": {
+					Var:    "G",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(-(86400*2 + 3600*3 + 60*4 + 5)),
+				},
+				"H": {
+					Var:    "H",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(899.99),
+				},
+			},
+		},
+		expected: "0s:1s:1m 0s:1h 0m 0s:1d 0h 0m 0s:1d 1h 0m 0s:-2d 3h 4m 5s:14m 59s:",
+	}, {
+		name: "humanizeDuration - string",
+		text: "{{ humanizeDuration $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "86400",
+		},
+		expected: "1d 0h 0m 0s",
+	}, {
+		name: "humanizeDuration - subsecond and fractional seconds - float64",
+		text: "{{ range $key, $val := $values }}{{ humanizeDuration .Value }}:{{ end }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(.1),
+				},
+				"B": {
+					Var:    "B",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(.0001),
+				},
+				"C": {
+					Var:    "C",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(.12345),
+				},
+				"D": {
+					Var:    "D",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(60.1),
+				},
+				"E": {
+					Var:    "E",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(60.5),
+				},
+				"F": {
+					Var:    "F",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(1.2345),
+				},
+				"G": {
+					Var:    "G",
+					Labels: data.Labels{},
+					Value:  ptr.Float64(12.345),
+				},
+			},
+		},
+		expected: "100ms:100us:123.5ms:1m 0s:1m 0s:1.234s:12.35s:",
+	}, {
+		name: "humanizeDuration - subsecond - string",
+		text: "{{ humanizeDuration $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: ".0001",
+		},
+		expected: "100us",
+	}, {
+		name: "humanizeDuration - fractional seconds - string",
+		text: "{{ humanizeDuration $value }}",
+		alertInstance: eval.Result{
+			EvaluationString: "1.2345",
+		},
+		expected: "1.234s",
+	}, {
+		name: "humanizeDuration - string with error",
+		text: `{{ humanizeDuration $value }}`,
+		alertInstance: eval.Result{
+			EvaluationString: "invalid",
+		},
+		expectedError: errors.New(`error executing template __alert_test: template: __alert_test:1:79: executing "__alert_test" at <humanizeDuration $value>: error calling humanizeDuration: strconv.ParseFloat: parsing "invalid": invalid syntax`),
+	}, {
+		name:     "humanizePercentage - float64",
+		text:     "{{ -0.22222 | humanizePercentage }}:{{ 0.0 | humanizePercentage }}:{{ 0.1234567 | humanizePercentage }}:{{ 1.23456 | humanizePercentage }}",
+		expected: "-22.22%:0%:12.35%:123.5%",
+	}, {
+		name:     "humanizePercentage - string",
+		text:     `{{ "-0.22222" | humanizePercentage }}:{{ "0.0" | humanizePercentage }}:{{ "0.1234567" | humanizePercentage }}:{{ "1.23456" | humanizePercentage }}`,
+		expected: "-22.22%:0%:12.35%:123.5%",
+	}, {
+		name:          "humanizePercentage - string with error",
+		text:          `{{ "invalid" | humanizePercentage }}`,
+		expectedError: errors.New(`error executing template __alert_test: template: __alert_test:1:91: executing "__alert_test" at <humanizePercentage>: error calling humanizePercentage: strconv.ParseFloat: parsing "invalid": invalid syntax`),
+	}, {
+		name:     "humanizeTimestamp - float64",
+		text:     "{{ 1435065584.128 | humanizeTimestamp }}",
+		expected: "2015-06-23 13:19:44.128 +0000 UTC",
+	}, {
+		name:     "humanizeTimestamp - string",
+		text:     `{{ "1435065584.128" | humanizeTimestamp }}`,
+		expected: "2015-06-23 13:19:44.128 +0000 UTC",
+	}, {
+		name:     "title",
+		text:     `{{ "aa bb CC" | title }}`,
+		expected: "Aa Bb CC",
+	}, {
+		name:     "toUpper",
+		text:     `{{ "aa bb CC" | toUpper }}`,
+		expected: "AA BB CC",
+	}, {
+		name:     "toLower",
+		text:     `{{ "aA bB CC" | toLower }}`,
+		expected: "aa bb cc",
+	}, {
+		name:     "match",
+		text:     `{{ match "a+" "aa" }} {{ match "a+" "b" }}`,
+		expected: "true false",
+	}, {
+		name:     "regex replacement",
+		text:     "{{ reReplaceAll \"(a)b\" \"x$1\" \"ab\" }}",
+		expected: "xa",
+	}, {
+		name:     "pass multiple arguments to templates",
+		text:     `{{define "x"}}{{.arg0}} {{.arg1}}{{end}}{{template "x" (args 1 "2")}}`,
+		expected: "1 2",
+	}, {
+		name:     "pathPrefix",
+		text:     "{{ pathPrefix }}",
+		expected: pathPrefix,
+	}, {
+		name:     "externalURL",
+		text:     "{{ externalURL }}",
+		expected: externalURL.String(),
+	}, {
+		name:     "check that query, first and value don't error or panic",
+		text:     "{{ query \"1.5\" | first | value }}",
+		expected: "",
+	}, {
+		name:     "check that label doesn't error or panic",
+		text:     "{{ query \"metric{instance='a'}\" | first | label \"instance\" }}",
+		expected: "",
+	}, {
+		name:     "check that graphLink returns an empty string",
+		text:     "{{ graphLink \"up\" }}",
+		expected: "",
+	}, {
+		name:     "check that tableLink returns an empty string",
+		text:     "{{ tableLink \"up\" }}",
+		expected: "",
+	}, {
+		name:     "check that sortByLabel doesn't error or panic",
+		text:     "{{ query \"metric{__value__='a'}\" | sortByLabel }}",
+		expected: "",
+	}, {
+		name: "check that strvalue returns an empty string (for now)",
+		text: "{{ $values.A | strvalue }}",
+		alertInstance: eval.Result{
+			Values: map[string]eval.NumberValueCapture{
+				"A": {
+					Var:    "A",
+					Labels: data.Labels{"__value__": "foo"},
+				},
+			},
+		},
+		expected: "",
+	}, {
+		name:     "check that safeHtml doesn't error or panic",
+		text:     "{{ \"<b>\" | safeHtml }}",
+		expected: "<b>",
+	},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			v, err := expandTemplate("test", c.text, c.labels, c.alertInstance, externalURL)
+			if c.expectedError != nil {
+				require.NotNil(t, err)
+				require.EqualError(t, c.expectedError, err.Error())
+			} else {
+				require.Nil(t, c.expectedError)
+			}
+			require.Equal(t, c.expected, v)
+		})
+	}
+}