From ddc888ea3ded4f7f05355b36c2cbfdbeb6e0e1cc Mon Sep 17 00:00:00 2001 From: Gabriel Cosi Date: Fri, 26 Jun 2026 08:09:40 +0200 Subject: [PATCH] feat: add humanize value helper with SI metric prefixes Signed-off-by: Gabriel Cosi --- README.md | 1 + internal/kromgo/expr.go | 1 + internal/kromgo/expr_test.go | 1 + internal/kromgo/formatting.go | 36 ++++++++++++++++++++++++++++++ internal/kromgo/formatting_test.go | 35 +++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/README.md b/README.md index 8756438..a4a09c5 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ humanize dependency, so the output is exactly as below): | Function | Example | Result | Notes | | ------------------------------ | --------------------------------- | --------- | ------------------------------------------- | +| `humanize(result)` | `humanize(93166031.0)` | `93.17M` | SI metric prefixes (powers of 1000), 4 sig figs, unit-less | | `humanizeBytes(result)` | `humanizeBytes(1500000.0)` | `1.5MB` | SI decimal units (powers of 1000), no space | | `humanizeCommas(result)` | `humanizeCommas(157121.0)` | `157,121` | comma thousands grouping | | `humanizeFloat(result)` | `humanizeFloat(2.50)` | `2.5` | plain decimal, trailing zeros stripped | diff --git a/internal/kromgo/expr.go b/internal/kromgo/expr.go index 92cfb78..f868c57 100644 --- a/internal/kromgo/expr.go +++ b/internal/kromgo/expr.go @@ -44,6 +44,7 @@ func unaryStringFunc(name string, impl func(float64) string) cel.EnvOption { // humanizerFuncs registers kromgo's value-formatting helpers (see formatting.go). func humanizerFuncs() []cel.EnvOption { return []cel.EnvOption{ + unaryStringFunc("humanize", humanize), unaryStringFunc("humanizeBytes", humanizeBytes), unaryStringFunc("humanizeCommas", humanizeCommas), unaryStringFunc("humanizeFloat", humanizeFloat), diff --git a/internal/kromgo/expr_test.go b/internal/kromgo/expr_test.go index 96a963f..f8aabe0 100644 --- a/internal/kromgo/expr_test.go +++ b/internal/kromgo/expr_test.go @@ -33,6 +33,7 @@ func TestCEL_Expressions(t *testing.T) { {"int threshold", `result < 35 ? "green" : "red"`, 17.5, nil, "green"}, {"int threshold high", `result < 35 ? "green" : "red"`, 80, nil, "red"}, {"label", `labels["version"]`, 0, map[string]string{"version": "v1.2.3"}, "v1.2.3"}, + {"humanize", `humanize(result)`, 93166031, nil, "93.17M"}, {"humanizeBytes", `humanizeBytes(result)`, 1572864, nil, "1.6MB"}, {"humanizeDuration short", `humanizeDuration(result)`, 9000, nil, "2h30m"}, {"humanizeDuration long", `humanizeDuration(result)`, 467 * 86400, nil, "1y3mo12d"}, diff --git a/internal/kromgo/formatting.go b/internal/kromgo/formatting.go index a1994ea..36c5d7a 100644 --- a/internal/kromgo/formatting.go +++ b/internal/kromgo/formatting.go @@ -36,6 +36,42 @@ func humanizeBytes(f float64) string { return trimOneDecimal(v) + byteUnits[i] } +// SI prefix ladders for humanize (powers of 1000): metricPrefixes for |f| >= 1, +// smallMetricPrefixes for |f| < 1. +var ( + metricPrefixes = [...]string{"k", "M", "G", "T", "P", "E", "Z", "Y"} + smallMetricPrefixes = [...]string{"m", "u", "n", "p", "f", "a", "z", "y"} +) + +// humanize formats f with SI metric prefixes and four significant figures, like +// Prometheus's `humanize`: 93166031 -> "93.17M", 1000 -> "1k", 0.0000123 -> "12.3u". +// Unit-less; 0/NaN/Inf degrade via humanizeFloat. +func humanize(f float64) string { + if f == 0 || math.IsInf(f, 0) || math.IsNaN(f) { + return humanizeFloat(f) // "0" / "+Inf" / "-Inf" / "NaN" + } + if math.Abs(f) >= 1 { + prefix := "" + for _, p := range metricPrefixes { + if math.Abs(f) < 1000 { + break + } + prefix = p + f /= 1000 + } + return fmt.Sprintf("%.4g%s", f, prefix) + } + prefix := "" + for _, p := range smallMetricPrefixes { + if math.Abs(f) >= 1 { + break + } + prefix = p + f *= 1000 + } + return fmt.Sprintf("%.4g%s", f, prefix) +} + // humanizeCommas formats a number with comma thousands separators in the integer part, // e.g. 157121 -> "157,121", 1234.56 -> "1,234.56", -1234 -> "-1,234". func humanizeCommas(f float64) string { diff --git a/internal/kromgo/formatting_test.go b/internal/kromgo/formatting_test.go index c876598..10f2eb3 100644 --- a/internal/kromgo/formatting_test.go +++ b/internal/kromgo/formatting_test.go @@ -96,3 +96,38 @@ func TestHumanizeDuration(t *testing.T) { }) } } + +func TestHumanize(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in float64 + want string + }{ + // |f| >= 1: scale down, metric prefixes, 4 sig figs. + {"plain", 999, "999"}, + {"exact k", 1000, "1k"}, + {"thousands", 1500, "1.5k"}, + {"k four sig figs", 157121, "157.1k"}, + {"exact M", 1000000, "1M"}, + {"millions", 93166031, "93.17M"}, + {"giga", 2147483648, "2.147G"}, + // Prometheus parity: 999.999k rounds to "1000k", no carry. + {"rounds within prefix", 999999, "1000k"}, + // |f| < 1: scale up, small prefixes. + {"milli", 0.5, "500m"}, + {"micro", 0.0000123, "12.3u"}, + {"negative", -1500, "-1.5k"}, + // 0/NaN/Inf degrade cleanly (no "0y" / "NaNk"). + {"zero", 0, "0"}, + {"NaN", math.NaN(), "NaN"}, + {"+Inf", math.Inf(1), "+Inf"}, + {"-Inf", math.Inf(-1), "-Inf"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, humanize(tc.in)) + }) + } +}