Repository: prometheus/common
Pull request #627 (2024-04-29 21:30:32)

feat: add time template helpers
Copied from here https://github.com/prometheus/prometheus/blob/34ee8c607809cca973a1d7383713035045f681d8/template/template.go#L267, the eventual goal is to reuse it in Alertmanager as well to allow it as a template helper, and also in Prometheus.

Related: https://github.com/prometheus/alertmanager/issues/3717
Related: https://github.com/prometheus/alertmanager/pull/3720#issuecomment-1968901170

Function itself is moved as is, tests are updated as there's no templates rendering here.

Also ~~added the `.idea` to `.gitignore` (as I use Goland IDE and it creates some files that are probably not to be committed) and~~ added the go.mod entry to go.mod and go.sum (otherwise `make test` would fail).

freak12techno: @gotjosh can you look at this?

Patch:
diff --git a/go.mod b/go.mod
index 4937716..37dfa66 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f
 	github.com/prometheus/client_model v0.6.1
+	github.com/stretchr/testify v1.8.2
 	golang.org/x/net v0.24.0
 	golang.org/x/oauth2 v0.19.0
 	google.golang.org/protobuf v1.34.0
@@ -19,8 +20,10 @@ require (
 	github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-logfmt/logfmt v0.5.1 // indirect
 	github.com/jpillora/backoff v1.0.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_golang v1.19.0 // indirect
 	github.com/prometheus/procfs v0.12.0 // indirect
 	github.com/rogpeppe/go-internal v1.10.0 // indirect
@@ -28,6 +31,7 @@ require (
 	golang.org/x/sys v0.19.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
 retract v0.50.0 // Critical bug in counter suffixes, please read issue https://github.com/prometheus/common/issues/605
diff --git a/go.sum b/go.sum
index 60ffee1..951e7da 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,7 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
 github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
 github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
@@ -36,8 +37,13 @@ github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3c
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
 golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
@@ -56,4 +62,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/helpers/templates/time.go b/helpers/templates/time.go
new file mode 100644
index 0000000..266c8c9
--- /dev/null
+++ b/helpers/templates/time.go
@@ -0,0 +1,89 @@
+// Copyright 2024 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 templates
+
+import (
+	"fmt"
+	"math"
+	"strconv"
+	"time"
+)
+
+func convertToFloat(i interface{}) (float64, error) {
+	switch v := i.(type) {
+	case float64:
+		return v, nil
+	case string:
+		return strconv.ParseFloat(v, 64)
+	case int:
+		return float64(v), nil
+	case uint:
+		return float64(v), nil
+	case int64:
+		return float64(v), nil
+	case uint64:
+		return float64(v), nil
+	case time.Duration:
+		return v.Seconds(), nil
+	default:
+		return 0, fmt.Errorf("can't convert %T to float", v)
+	}
+}
+
+func HumanizeDuration(i interface{}) (string, error) {
+	v, err := convertToFloat(i)
+	if err != nil {
+		return "", err
+	}
+
+	if math.IsNaN(v) || math.IsInf(v, 0) {
+		return fmt.Sprintf("%.4g", v), nil
+	}
+	if v == 0 {
+		return fmt.Sprintf("%.4gs", v), nil
+	}
+	if math.Abs(v) >= 1 {
+		sign := ""
+		if v < 0 {
+			sign = "-"
+			v = -v
+		}
+		duration := int64(v)
+		seconds := duration % 60
+		minutes := (duration / 60) % 60
+		hours := (duration / 60 / 60) % 24
+		days := duration / 60 / 60 / 24
+		// For days to minutes, we display seconds as an integer.
+		if days != 0 {
+			return fmt.Sprintf("%s%dd %dh %dm %ds", sign, days, hours, minutes, seconds), nil
+		}
+		if hours != 0 {
+			return fmt.Sprintf("%s%dh %dm %ds", sign, hours, minutes, seconds), nil
+		}
+		if minutes != 0 {
+			return fmt.Sprintf("%s%dm %ds", sign, minutes, seconds), nil
+		}
+		// For seconds, we display 4 significant digits.
+		return fmt.Sprintf("%s%.4gs", sign, v), nil
+	}
+	prefix := ""
+	for _, p := range []string{"m", "u", "n", "p", "f", "a", "z", "y"} {
+		if math.Abs(v) >= 1 {
+			break
+		}
+		prefix = p
+		v *= 1000
+	}
+	return fmt.Sprintf("%.4g%ss", v, prefix), nil
+}


Test patch:
diff --git a/helpers/templates/time_test.go b/helpers/templates/time_test.go
new file mode 100644
index 0000000..8c59b21
--- /dev/null
+++ b/helpers/templates/time_test.go
@@ -0,0 +1,141 @@
+// Copyright 2024 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 templates
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestHumanizeDurationSecondsFloat64(t *testing.T) {
+	tc := []struct {
+		name     string
+		input    float64
+		expected string
+	}{
+		{name: "zero", input: 0, expected: "0s"},
+		{name: "one second", input: 1, expected: "1s"},
+		{name: "one minute", input: 60, expected: "1m 0s"},
+		{name: "one hour", input: 3600, expected: "1h 0m 0s"},
+		{name: "one day", input: 86400, expected: "1d 0h 0m 0s"},
+		{name: "one day and one hour", input: 86400 + 3600, expected: "1d 1h 0m 0s"},
+		{name: "negative duration", input: -(86400*2 + 3600*3 + 60*4 + 5), expected: "-2d 3h 4m 5s"},
+		{name: "using a float", input: 899.99, expected: "14m 59s"},
+	}
+
+	for _, tt := range tc {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := HumanizeDuration(tt.input)
+			require.NoError(t, err)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestHumanizeDurationSubsecondAndFractionalSecondsFloat64(t *testing.T) {
+	tc := []struct {
+		name     string
+		input    float64
+		expected string
+	}{
+		{name: "millseconds", input: .1, expected: "100ms"},
+		{name: "nanoseconds", input: .0001, expected: "100us"},
+		{name: "milliseconds + nanoseconds", input: .12345, expected: "123.5ms"},
+		{name: "minute + millisecond", input: 60.1, expected: "1m 0s"},
+		{name: "minute + milliseconds", input: 60.5, expected: "1m 0s"},
+		{name: "second + milliseconds", input: 1.2345, expected: "1.234s"},
+		{name: "second + milliseconds rounded", input: 12.345, expected: "12.35s"},
+	}
+
+	for _, tt := range tc {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := HumanizeDuration(tt.input)
+			require.NoError(t, err)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestHumanizeDurationErrorString(t *testing.T) {
+	_, err := HumanizeDuration("one")
+	require.Error(t, err)
+}
+
+func TestHumanizeDurationSecondsString(t *testing.T) {
+	tc := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{name: "zero", input: "0", expected: "0s"},
+		{name: "second", input: "1", expected: "1s"},
+		{name: "minute", input: "60", expected: "1m 0s"},
+		{name: "hour", input: "3600", expected: "1h 0m 0s"},
+		{name: "day", input: "86400", expected: "1d 0h 0m 0s"},
+	}
+
+	for _, tt := range tc {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := HumanizeDuration(tt.input)
+			require.NoError(t, err)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestHumanizeDurationSubsecondAndFractionalSecondsString(t *testing.T) {
+	tc := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{name: "millseconds", input: ".1", expected: "100ms"},
+		{name: "nanoseconds", input: ".0001", expected: "100us"},
+		{name: "milliseconds + nanoseconds", input: ".12345", expected: "123.5ms"},
+		{name: "minute + millisecond", input: "60.1", expected: "1m 0s"},
+		{name: "minute + milliseconds", input: "60.5", expected: "1m 0s"},
+		{name: "second + milliseconds", input: "1.2345", expected: "1.234s"},
+		{name: "second + milliseconds rounded", input: "12.345", expected: "12.35s"},
+	}
+
+	for _, tt := range tc {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := HumanizeDuration(tt.input)
+			require.NoError(t, err)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestHumanizeDurationSecondsInt(t *testing.T) {
+	tc := []struct {
+		name     string
+		input    int
+		expected string
+	}{
+		{name: "zero", input: 0, expected: "0s"},
+		{name: "negative", input: -1, expected: "-1s"},
+		{name: "second", input: 1, expected: "1s"},
+		{name: "days", input: 1234567, expected: "14d 6h 56m 7s"},
+	}
+
+	for _, tt := range tc {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := HumanizeDuration(tt.input)
+			require.NoError(t, err)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}