Repository: prometheus/common
Pull request #654 (2024-06-10 00:46:10)

chore: add HumanizeTimestamp; make ConvertToFloat exportable
See the discussion here: https://github.com/prometheus/prometheus/pull/14202

Basically copied HumanizeTimestamp from prometheus repo to move it there and added some tests (basically refactored to avoid using templating), the idea is to remove its implementation and the implementation of ConvertToFloat (which are both used in alertmanager and prometheus repos) from both prometheus and alertmanager repos to include the implementation from this repo.

@gotjosh can you check?


Patch:
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 5ceb590..8de7af6 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -26,7 +26,7 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
       - name: Install Go
-        uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
+        uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
         with:
           go-version: 1.22.x
       - name: Install snmp_exporter/generator dependencies
diff --git a/config/http_config.go b/config/http_config.go
index c89f586..3e32013 100644
--- a/config/http_config.go
+++ b/config/http_config.go
@@ -323,7 +323,7 @@ type HTTPClientConfig struct {
 	ProxyConfig `yaml:",inline"`
 	// HTTPHeaders specify headers to inject in the requests. Those headers
 	// could be marshalled back to the users.
-	HTTPHeaders *Headers `yaml:"http_headers" json:"http_headers"`
+	HTTPHeaders *Headers `yaml:"http_headers,omitempty" json:"http_headers,omitempty"`
 }
 
 // SetDirectory joins any relative file paths with dir.
diff --git a/helpers/templates/time.go b/helpers/templates/time.go
index 266c8c9..b7dc655 100644
--- a/helpers/templates/time.go
+++ b/helpers/templates/time.go
@@ -14,13 +14,18 @@
 package templates
 
 import (
+	"errors"
 	"fmt"
 	"math"
 	"strconv"
 	"time"
+
+	"github.com/prometheus/common/model"
 )
 
-func convertToFloat(i interface{}) (float64, error) {
+var errNaNOrInf = errors.New("value is NaN or Inf")
+
+func ConvertToFloat(i interface{}) (float64, error) {
 	switch v := i.(type) {
 	case float64:
 		return v, nil
@@ -41,8 +46,20 @@ func convertToFloat(i interface{}) (float64, error) {
 	}
 }
 
+func FloatToTime(v float64) (*time.Time, error) {
+	if math.IsNaN(v) || math.IsInf(v, 0) {
+		return nil, errNaNOrInf
+	}
+	timestamp := v * 1e9
+	if timestamp > math.MaxInt64 || timestamp < math.MinInt64 {
+		return nil, fmt.Errorf("%v cannot be represented as a nanoseconds timestamp since it overflows int64", v)
+	}
+	t := model.TimeFromUnixNano(int64(timestamp)).Time().UTC()
+	return &t, nil
+}
+
 func HumanizeDuration(i interface{}) (string, error) {
-	v, err := convertToFloat(i)
+	v, err := ConvertToFloat(i)
 	if err != nil {
 		return "", err
 	}
@@ -87,3 +104,20 @@ func HumanizeDuration(i interface{}) (string, error) {
 	}
 	return fmt.Sprintf("%.4g%ss", v, prefix), nil
 }
+
+func HumanizeTimestamp(i interface{}) (string, error) {
+	v, err := ConvertToFloat(i)
+	if err != nil {
+		return "", err
+	}
+
+	tm, err := FloatToTime(v)
+	switch {
+	case errors.Is(err, errNaNOrInf):
+		return fmt.Sprintf("%.4g", v), nil
+	case err != nil:
+		return "", err
+	}
+
+	return fmt.Sprint(tm), nil
+}


Test patch:
diff --git a/config/http_config_test.go b/config/http_config_test.go
index 3e7a126..5be8438 100644
--- a/config/http_config_test.go
+++ b/config/http_config_test.go
@@ -2023,7 +2023,6 @@ func TestHTTPClientConfig_Marshal(t *testing.T) {
 proxy_url: "http://localhost:8080"
 follow_redirects: false
 enable_http2: false
-http_headers: null
 `, string(actualYAML))
 
 			// Unmarshalling the YAML should get the same struct in input.
@@ -2040,8 +2039,7 @@ http_headers: null
 				"proxy_url":"http://localhost:8080",
 				"tls_config":{"insecure_skip_verify":false},
 				"follow_redirects":false,
-				"enable_http2":false,
-				"http_headers":null
+				"enable_http2":false
 			}`, string(actualJSON))
 
 			// Unmarshalling the JSON should get the same struct in input.
diff --git a/helpers/templates/time_test.go b/helpers/templates/time_test.go
index 8c59b21..911216e 100644
--- a/helpers/templates/time_test.go
+++ b/helpers/templates/time_test.go
@@ -14,17 +14,19 @@
 package templates
 
 import (
+	"math"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 )
 
-func TestHumanizeDurationSecondsFloat64(t *testing.T) {
+func TestHumanizeDuration(t *testing.T) {
 	tc := []struct {
 		name     string
-		input    float64
+		input    interface{}
 		expected string
 	}{
+		// Integers
 		{name: "zero", input: 0, expected: "0s"},
 		{name: "one second", input: 1, expected: "1s"},
 		{name: "one minute", input: 60, expected: "1m 0s"},
@@ -32,24 +34,8 @@ func TestHumanizeDurationSecondsFloat64(t *testing.T) {
 		{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"},
+		// Float64 with fractions
 		{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"},
@@ -57,50 +43,13 @@ func TestHumanizeDurationSubsecondAndFractionalSecondsFloat64(t *testing.T) {
 		{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
-	}{
+		// 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
-	}{
+		// String with fractions
 		{name: "millseconds", input: ".1", expected: "100ms"},
 		{name: "nanoseconds", input: ".0001", expected: "100us"},
 		{name: "milliseconds + nanoseconds", input: ".12345", expected: "123.5ms"},
@@ -108,6 +57,11 @@ func TestHumanizeDurationSubsecondAndFractionalSecondsString(t *testing.T) {
 		{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"},
+		// Int
+		{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 {
@@ -119,23 +73,51 @@ func TestHumanizeDurationSubsecondAndFractionalSecondsString(t *testing.T) {
 	}
 }
 
-func TestHumanizeDurationSecondsInt(t *testing.T) {
+func TestHumanizeDurationErrorString(t *testing.T) {
+	_, err := HumanizeDuration("one")
+	require.Error(t, err)
+}
+
+func TestHumanizeTimestamp(t *testing.T) {
 	tc := []struct {
 		name     string
-		input    int
+		input    interface{}
 		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"},
+		// Int
+		{name: "zero", input: 0, expected: "1970-01-01 00:00:00 +0000 UTC"},
+		{name: "negative", input: -1, expected: "1969-12-31 23:59:59 +0000 UTC"},
+		{name: "one", input: 1, expected: "1970-01-01 00:00:01 +0000 UTC"},
+		{name: "past", input: 1234567, expected: "1970-01-15 06:56:07 +0000 UTC"},
+		{name: "future", input: 9223372036, expected: "2262-04-11 23:47:16 +0000 UTC"},
+		// Uint
+		{name: "zero", input: uint64(0), expected: "1970-01-01 00:00:00 +0000 UTC"},
+		{name: "one", input: uint64(1), expected: "1970-01-01 00:00:01 +0000 UTC"},
+		{name: "past", input: uint64(1234567), expected: "1970-01-15 06:56:07 +0000 UTC"},
+		{name: "future", input: uint64(9223372036), expected: "2262-04-11 23:47:16 +0000 UTC"},
+		// NaN/Inf, strings
+		{name: "infinity", input: "+Inf", expected: "+Inf"},
+		{name: "minus infinity", input: "-Inf", expected: "-Inf"},
+		{name: "NaN", input: "NaN", expected: "NaN"},
+		// Nan/Inf, float64
+		{name: "infinity", input: math.Inf(1), expected: "+Inf"},
+		{name: "minus infinity", input: math.Inf(-1), expected: "-Inf"},
+		{name: "NaN", input: math.NaN(), expected: "NaN"},
+		// Sampled data
+		{name: "sample float64", input: 1435065584.128, expected: "2015-06-23 13:19:44.128 +0000 UTC"},
+		{name: "sample string", input: "1435065584.128", expected: "2015-06-23 13:19:44.128 +0000 UTC"},
 	}
 
 	for _, tt := range tc {
 		t.Run(tt.name, func(t *testing.T) {
-			result, err := HumanizeDuration(tt.input)
+			result, err := HumanizeTimestamp(tt.input)
 			require.NoError(t, err)
 			require.Equal(t, tt.expected, result)
 		})
 	}
 }
+
+func TestHumanizeTimestampError(t *testing.T) {
+	_, err := HumanizeTimestamp(math.MaxInt64)
+	require.Error(t, err)
+}