diff --git a/README.md b/README.md index 663d736..f24d7a6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Part of the [Prometheus](https://prometheus.io/) ecosystem, following the [OpenT - **Namespace Support**: Add configurable namespace prefixes - **UTF-8 Support**: Choose between Prometheus legacy scheme compliant metric/label names (`[a-zA-Z0-9:_]`) or untranslated metric/label names - **Translation Strategy Configuration**: Select a translation strategy with a standard set of strings. +- **Updated UCUM Unit Mappings**: Opt in to corrected UCUM unit suffixes (`TiBy`: `tebibytes`, `kBy`: `kilobytes`) by selecting the `UnderscoreEscapingWithUpdatedSuffixes` or `NoUTF8EscapingWithUpdatedSuffixes` translation strategy. ## Installation @@ -31,7 +32,9 @@ import ( func main() { // Create a metric namer using traditional Prometheus name translation, with suffixes added and UTF-8 disallowed. - strategy := otlptranslator.UnderscoreEscapingWithSuffixes + // Use UnderscoreEscapingWithUpdatedSuffixes to opt into corrected UCUM unit mappings: + // TiBy -> "tebibytes" instead of "tibibytes" and kBy -> "kilobytes". + strategy := otlptranslator.UnderscoreEscapingWithUpdatedSuffixes namer := otlptranslator.NewMetricNamer("myapp", strategy) // Translate OTLP metric to Prometheus format diff --git a/metric_namer.go b/metric_namer.go index d958a0f..15f1334 100644 --- a/metric_namer.go +++ b/metric_namer.go @@ -67,6 +67,11 @@ var unitMap = map[string]string{ "%": "percent", } +var updatedUnitMap = map[string]string{ + "TiBy": "tebibytes", + "kBy": "kilobytes", +} + // The map that translates the "per" unit. // Example: s => per second (singular). var perUnitMap = map[string]string{ @@ -97,19 +102,22 @@ var perUnitMap = map[string]string{ // // result := namer.Build(metric) // "http_server_duration_seconds" type MetricNamer struct { - Namespace string - WithMetricSuffixes bool - UTF8Allowed bool + Namespace string + WithMetricSuffixes bool + UTF8Allowed bool + UpdatedMetricMapping bool // to fix the UCUM metrics suffix tebibyte and kBy } // NewMetricNamer creates a MetricNamer with the specified namespace (can be // blank) and the requested Translation Strategy. func NewMetricNamer(namespace string, strategy TranslationStrategyOption) MetricNamer { - return MetricNamer{ - Namespace: namespace, - WithMetricSuffixes: strategy.ShouldAddSuffixes(), - UTF8Allowed: !strategy.ShouldEscape(), + mn := MetricNamer{ + Namespace: namespace, + WithMetricSuffixes: strategy.ShouldAddSuffixes(), + UTF8Allowed: !strategy.ShouldEscape(), + UpdatedMetricMapping: strategy.ShouldUseUpdatedSuffixes(), } + return mn } // Metric is a helper struct that holds information about a metric. @@ -178,7 +186,7 @@ func (mn *MetricNamer) buildCompliantMetricName(name, unit string, metricType Me // Full normalization following standard Prometheus naming conventions if mn.WithMetricSuffixes { - normalizedName = normalizeName(name, unit, metricType, mn.Namespace) + normalizedName = normalizeName(name, unit, metricType, mn.Namespace, mn.UpdatedMetricMapping) return } @@ -222,7 +230,7 @@ func replaceInvalidMetricChar(r rune) rune { } // Build a normalized name for the specified metric. -func normalizeName(name, unit string, metricType MetricType, namespace string) string { +func normalizeName(name, unit string, metricType MetricType, namespace string, updatedMetricsMapping bool) string { // Split metric name into "tokens" (of supported metric name runes). // Note that this has the side effect of replacing multiple consecutive underscores with a single underscore. // This is part of the OTel to Prometheus specification: https://github.com/open-telemetry/opentelemetry-specification/blob/v1.38.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus. @@ -231,7 +239,7 @@ func normalizeName(name, unit string, metricType MetricType, namespace string) s func(r rune) bool { return !isValidCompliantMetricChar(r) }, ) - mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit) + mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit, updatedMetricsMapping) nameTokens = addUnitTokens(nameTokens, cleanUpUnit(mainUnitSuffix), cleanUpUnit(perUnitSuffix)) // Append _total for Counters @@ -335,7 +343,7 @@ func (mn *MetricNamer) buildMetricName(inputName, unit string, metricType Metric }() } - mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit) + mainUnitSuffix, perUnitSuffix := buildUnitSuffixes(unit, mn.UpdatedMetricMapping) if perUnitSuffix != "" { name = trimSuffixAndDelimiter(name, perUnitSuffix) defer func() { diff --git a/metric_namer_test.go b/metric_namer_test.go index adfd2db..3137365 100644 --- a/metric_namer_test.go +++ b/metric_namer_test.go @@ -804,6 +804,43 @@ func TestMetricNamer_Build(t *testing.T) { wantMetricName: "capacity_tibibytes", wantUnitName: "tibibytes", }, + { + name: "metric with tebibytes unit with flag", + namer: MetricNamer{ + UTF8Allowed: false, + WithMetricSuffixes: true, + UpdatedMetricMapping: true, + }, + metric: Metric{ + Name: "capacity", + Unit: "TiBy", + Type: MetricTypeGauge, + }, + wantMetricName: "capacity_tebibytes", + wantUnitName: "tebibytes", + }, + { + name: "metrics with updated mapping using NoUTF8EscapingWithUpdatedSuffixes flag", + namer: NewMetricNamer("mock", NoUTF8EscapingWithUpdatedSuffixes), + metric: Metric{ + Name: "capacity", + Unit: "TiBy", + Type: MetricTypeGauge, + }, + wantMetricName: "mock_capacity_tebibytes", + wantUnitName: "tebibytes", + }, + { + name: "metrics with updated mapping using UnderscoreEscapingWithUpdatedSuffixes flag", + namer: NewMetricNamer("mock", UnderscoreEscapingWithUpdatedSuffixes), + metric: Metric{ + Name: "capacity", + Unit: "TiBy", + Type: MetricTypeGauge, + }, + wantMetricName: "mock_capacity_tebibytes", + wantUnitName: "tebibytes", + }, { name: "metric with kilobytes unit", namer: MetricNamer{ @@ -818,6 +855,22 @@ func TestMetricNamer_Build(t *testing.T) { wantMetricName: "transfer_kilobytes", wantUnitName: "kilobytes", }, + + { + name: "metric with kilobytes unit with flag", + namer: MetricNamer{ + UTF8Allowed: false, + WithMetricSuffixes: true, + UpdatedMetricMapping: true, + }, + metric: Metric{ + Name: "transfer", + Unit: "kBy", + Type: MetricTypeGauge, + }, + wantMetricName: "transfer_kilobytes", + wantUnitName: "kilobytes", + }, { name: "metric with megabytes unit", namer: MetricNamer{ @@ -1207,7 +1260,8 @@ func TestMetricNamer_Build(t *testing.T) { // Build unit name using UnitNamer to verify correlation when suffixes are enabled if tt.namer.WithMetricSuffixes { unitNamer := UnitNamer{ - UTF8Allowed: tt.namer.UTF8Allowed, + UTF8Allowed: tt.namer.UTF8Allowed, + UpdatedMetricMapping: tt.namer.UpdatedMetricMapping, } gotUnitName := unitNamer.Build(tt.metric.Unit) if gotUnitName != tt.wantUnitName { diff --git a/strategy.go b/strategy.go index 20fe019..37e5b3f 100644 --- a/strategy.go +++ b/strategy.go @@ -57,15 +57,26 @@ var ( // (https://github.com/prometheus/proposals/pull/39) once released, as // potential mitigation of the above risks. NoTranslation TranslationStrategyOption = "NoTranslation" + // NoUTF8EscapingWithUpdatedSuffixes will accept metric/label names as they are. Unit + // and type suffixes may be added to metric names, according to certain rules. + // This option includes an updated mapping for the UCUM metrics suffixes tebibyte and kBy. + NoUTF8EscapingWithUpdatedSuffixes TranslationStrategyOption = "NoUTF8EscapingWithUpdatedSuffixes" + // UnderscoreEscapingWithSuffixes is the default option for translating OTLP + // to Prometheus. This option will translate metric name characters that are + // not alphanumerics/underscores/colons to underscores, and label name + // characters that are not alphanumerics/underscores to underscores. Unit and + // type suffixes may be appended to metric names, according to certain rules. + // This option includes an updated mapping for the UCUM metrics suffixes tebibyte and kBy. + UnderscoreEscapingWithUpdatedSuffixes TranslationStrategyOption = "UnderscoreEscapingWithUpdatedSuffixes" ) // ShouldEscape returns true if the translation strategy requires that metric // names be escaped. func (o TranslationStrategyOption) ShouldEscape() bool { switch o { - case UnderscoreEscapingWithSuffixes, UnderscoreEscapingWithoutSuffixes: + case UnderscoreEscapingWithSuffixes, UnderscoreEscapingWithoutSuffixes, UnderscoreEscapingWithUpdatedSuffixes: return true - case NoTranslation, NoUTF8EscapingWithSuffixes: + case NoTranslation, NoUTF8EscapingWithSuffixes, NoUTF8EscapingWithUpdatedSuffixes: return false default: return false @@ -76,7 +87,7 @@ func (o TranslationStrategyOption) ShouldEscape() bool { // strategy should have suffixes added. func (o TranslationStrategyOption) ShouldAddSuffixes() bool { switch o { - case UnderscoreEscapingWithSuffixes, NoUTF8EscapingWithSuffixes: + case UnderscoreEscapingWithSuffixes, NoUTF8EscapingWithSuffixes, UnderscoreEscapingWithUpdatedSuffixes, NoUTF8EscapingWithUpdatedSuffixes: return true case UnderscoreEscapingWithoutSuffixes, NoTranslation: return false @@ -84,3 +95,14 @@ func (o TranslationStrategyOption) ShouldAddSuffixes() bool { return false } } + +func (o TranslationStrategyOption) ShouldUseUpdatedSuffixes() bool { + switch o { + case NoUTF8EscapingWithUpdatedSuffixes, UnderscoreEscapingWithUpdatedSuffixes: + return true + case NoUTF8EscapingWithSuffixes, UnderscoreEscapingWithSuffixes, UnderscoreEscapingWithoutSuffixes, NoTranslation: + return false + default: + return false + } +} diff --git a/unit_namer.go b/unit_namer.go index bb6d4f8..5f21517 100644 --- a/unit_namer.go +++ b/unit_namer.go @@ -24,7 +24,8 @@ import "strings" // result := namer.Build("s") // "seconds" // result = namer.Build("By/s") // "bytes_per_second" type UnitNamer struct { - UTF8Allowed bool + UTF8Allowed bool + UpdatedMetricMapping bool } // Build builds a unit name for the specified unit string. @@ -33,7 +34,7 @@ type UnitNamer struct { // // Unit mappings include: // - Time: s→seconds, ms→milliseconds, h→hours -// - Bytes: By→bytes, KBy→kilobytes, MBy→megabytes +// - Bytes: By→bytes, kBy→kilobytes, MBy→megabytes // - SI: m→meters, V→volts, W→watts // - Special: 1→"" (empty), %→percent // @@ -44,7 +45,7 @@ type UnitNamer struct { // namer.Build("requests/s") // "requests_per_second" // namer.Build("1") // "" (dimensionless) func (un *UnitNamer) Build(unit string) string { - mainUnit, perUnit := buildUnitSuffixes(unit) + mainUnit, perUnit := buildUnitSuffixes(unit, un.UpdatedMetricMapping) if !un.UTF8Allowed { mainUnit, perUnit = cleanUpUnit(mainUnit), cleanUpUnit(perUnit) } @@ -72,7 +73,15 @@ func (un *UnitNamer) Build(unit string) string { // Retrieve the Prometheus "basic" unit corresponding to the specified "basic" unit. // Returns the specified unit if not found in unitMap. -func unitMapGetOrDefault(unit string) string { +func unitMapGetOrDefault(unit string, updatedSuffix bool) string { + if updatedSuffix { + // checks for updatd values, TiBy <-> tebibytes and kBy <-> kilobytes. + if promUnit, ok := updatedUnitMap[unit]; ok { + return promUnit + } + // doesnt return cause what if the unit is not in the updated map but is + // in the original map, we want to check that as well. + } if promUnit, ok := unitMap[unit]; ok { return promUnit } @@ -91,7 +100,7 @@ func perUnitMapGetOrDefault(perUnit string) string { // buildUnitSuffixes builds the main and per unit suffixes for the specified unit // but doesn't do any special character transformation to accommodate Prometheus naming conventions. // Removing trailing underscores or appending suffixes is done in the caller. -func buildUnitSuffixes(unit string) (mainUnitSuffix, perUnitSuffix string) { +func buildUnitSuffixes(unit string, updatedMetricName bool) (mainUnitSuffix, perUnitSuffix string) { // Split unit at the '/' if any unitTokens := strings.SplitN(unit, "/", 2) @@ -100,7 +109,7 @@ func buildUnitSuffixes(unit string) (mainUnitSuffix, perUnitSuffix string) { // Update if not blank and doesn't contain '{}' mainUnitOTel := strings.TrimSpace(unitTokens[0]) if mainUnitOTel != "" && !strings.ContainsAny(mainUnitOTel, "{}") { - mainUnitSuffix = unitMapGetOrDefault(mainUnitOTel) + mainUnitSuffix = unitMapGetOrDefault(mainUnitOTel, updatedMetricName) } // Per unit