diff --git a/metric_namer.go b/metric_namer.go index 808dd77..21c45fc 100644 --- a/metric_namer.go +++ b/metric_namer.go @@ -229,34 +229,6 @@ func addUnitTokens(nameTokens []string, mainUnitSuffix, perUnitSuffix string) [] return nameTokens } -// cleanUpUnit cleans up unit so it matches model.LabelNameRE. -func cleanUpUnit(unit string) string { - // Multiple consecutive underscores are replaced 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. - return strings.TrimPrefix(multipleUnderscoresRE.ReplaceAllString( - strings.Map(replaceInvalidMetricChar, unit), - "_", - ), "_") -} - -// 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 { - if promUnit, ok := unitMap[unit]; ok { - return promUnit - } - return unit -} - -// Retrieve the Prometheus "per" unit corresponding to the specified "per" unit. -// Returns the specified unit if not found in perUnitMap. -func perUnitMapGetOrDefault(perUnit string) string { - if promPerUnit, ok := perUnitMap[perUnit]; ok { - return promPerUnit - } - return perUnit -} - // Remove the specified value from the slice. func removeItem(slice []string, value string) []string { newSlice := make([]string, 0, len(slice)) @@ -298,34 +270,3 @@ func (mn *MetricNamer) buildMetricName(name, unit string, metricType MetricType) } return name } - -// 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) { - // Split unit at the '/' if any - unitTokens := strings.SplitN(unit, "/", 2) - - if len(unitTokens) > 0 { - // Main unit - // Update if not blank and doesn't contain '{}' - mainUnitOTel := strings.TrimSpace(unitTokens[0]) - if mainUnitOTel != "" && !strings.ContainsAny(mainUnitOTel, "{}") { - mainUnitSuffix = unitMapGetOrDefault(mainUnitOTel) - } - - // Per unit - // Update if not blank and doesn't contain '{}' - if len(unitTokens) > 1 && unitTokens[1] != "" { - perUnitOTel := strings.TrimSpace(unitTokens[1]) - if perUnitOTel != "" && !strings.ContainsAny(perUnitOTel, "{}") { - perUnitSuffix = perUnitMapGetOrDefault(perUnitOTel) - } - if perUnitSuffix != "" { - perUnitSuffix = "per_" + perUnitSuffix - } - } - } - - return mainUnitSuffix, perUnitSuffix -} diff --git a/metric_namer_test.go b/metric_namer_test.go index c28bba1..ae86ee4 100644 --- a/metric_namer_test.go +++ b/metric_namer_test.go @@ -27,10 +27,11 @@ import ( func TestMetricNamer_Build(t *testing.T) { tests := []struct { - name string - namer MetricNamer - metric Metric - expected string + name string + namer MetricNamer + metric Metric + expectedMetricName string + expectedUnitName string }{ // UTF8Allowed = false, WithMetricSuffixes = false tests { @@ -44,7 +45,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "simple_metric", + expectedMetricName: "simple_metric", }, { name: "metric with special characters replaced", @@ -57,7 +58,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "metric_with_special_chars", + expectedMetricName: "metric_with_special_chars", }, { name: "metric starting with digit gets underscore prefix", @@ -70,7 +71,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "_123metric", + expectedMetricName: "_123metric", }, { name: "metric with namespace without suffixes", @@ -84,7 +85,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "test_namespace_simple_metric", + expectedMetricName: "test_namespace_simple_metric", }, { name: "empty metric name without suffixes", @@ -97,7 +98,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "", + expectedMetricName: "", }, { name: "metric with multiple consecutive special chars", @@ -110,7 +111,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "metric_name", + expectedMetricName: "metric_name", }, { name: "metric name with only special characters", @@ -123,9 +124,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "", + expectedMetricName: "", }, - { name: "namespace with special characters", namer: MetricNamer{ @@ -138,7 +138,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "test@namespace_metric", // TODO: should be "test_namespace_metric" + expectedMetricName: "test@namespace_metric", // TODO: should be "test_namespace_metric" + }, // UTF8Allowed = false, WithMetricSuffixes = true tests @@ -153,7 +154,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "requests_total", + expectedMetricName: "requests_total", }, { name: "gauge with unit 1 gets ratio suffix", @@ -166,7 +167,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeGauge, }, - expected: "cpu_usage_ratio", + expectedMetricName: "cpu_usage_ratio", }, { name: "counter with unit 1 does not get ratio suffix", @@ -179,7 +180,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeMonotonicCounter, }, - expected: "items_total", + expectedMetricName: "items_total", }, { name: "metric with time unit", @@ -192,7 +193,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ms", Type: MetricTypeGauge, }, - expected: "response_time_milliseconds", + expectedMetricName: "response_time_milliseconds", + expectedUnitName: "milliseconds", }, { name: "metric with bytes unit", @@ -205,7 +207,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "By", Type: MetricTypeGauge, }, - expected: "memory_usage_bytes", + expectedMetricName: "memory_usage_bytes", + expectedUnitName: "bytes", }, { name: "metric with per unit", @@ -218,7 +221,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/s", Type: MetricTypeGauge, }, - expected: "requests_per_second", + expectedMetricName: "requests_per_second", + expectedUnitName: "per_second", }, { name: "metric with complex per unit", @@ -231,7 +235,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "By/s", Type: MetricTypeGauge, }, - expected: "throughput_bytes_per_second", + expectedMetricName: "throughput_bytes_per_second", + expectedUnitName: "bytes_per_second", }, { name: "metric with unknown unit", @@ -244,7 +249,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "custom_unit", Type: MetricTypeGauge, }, - expected: "custom_metric_custom_unit", + expectedMetricName: "custom_metric_custom_unit", + expectedUnitName: "custom_unit", }, { name: "metric with unit containing braces is ignored", @@ -257,7 +263,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "{custom}", Type: MetricTypeGauge, }, - expected: "custom_metric", + expectedMetricName: "custom_metric", }, { name: "metric with per unit containing braces is ignored", @@ -270,7 +276,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "By/{custom}", Type: MetricTypeGauge, }, - expected: "custom_metric_bytes", + expectedMetricName: "custom_metric_bytes", + expectedUnitName: "bytes", }, { name: "metric name already contains total suffix", @@ -283,7 +290,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "requests_total", + expectedMetricName: "requests_total", }, { name: "metric name already contains ratio suffix", @@ -296,7 +303,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeGauge, }, - expected: "cpu_usage_ratio", + expectedMetricName: "cpu_usage_ratio", }, { name: "metric name already contains unit suffix", @@ -309,7 +316,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "s", Type: MetricTypeGauge, }, - expected: "response_time_seconds", + expectedMetricName: "response_time_seconds", + expectedUnitName: "seconds", }, { name: "metric with namespace and suffixes", @@ -323,7 +331,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/s", Type: MetricTypeMonotonicCounter, }, - expected: "app_requests_per_second_total", + expectedMetricName: "app_requests_per_second_total", + expectedUnitName: "per_second", }, { name: "metric starting with digit with namespace and suffixes", @@ -337,7 +346,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "app_123_requests_total", + expectedMetricName: "app_123_requests_total", }, { name: "metric with multiple underscores normalized", @@ -347,10 +356,11 @@ func TestMetricNamer_Build(t *testing.T) { }, metric: Metric{ Name: "metric__with__multiple__underscores", - Unit: "", + Unit: "unit__multiple__underscores", Type: MetricTypeGauge, }, - expected: "metric_with_multiple_underscores", + expectedMetricName: "metric_with_multiple_underscores_unit_multiple_underscores", + expectedUnitName: "unit_multiple_underscores", }, { name: "metric with special chars in unit", @@ -363,7 +373,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "unit@with#special/chars", Type: MetricTypeGauge, }, - expected: "custom_metric_unit_with_special_per_chars", + expectedMetricName: "custom_metric_unit_with_special_per_chars", + expectedUnitName: "unit_with_special_per_chars", }, { name: "metric name with only special characters", @@ -376,7 +387,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "", + expectedMetricName: "", }, // UTF8Allowed = true, WithMetricSuffixes = false tests @@ -391,7 +402,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "métric_with_ñ_chars", + expectedMetricName: "métric_with_ñ_chars", }, { name: "utf8 metric with namespace without suffixes", @@ -405,7 +416,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeGauge, }, - expected: "test_namespace_métric_with_ñ_chars", + expectedMetricName: "test_namespace_métric_with_ñ_chars", }, // UTF8Allowed = true, WithMetricSuffixes = true tests @@ -420,7 +431,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "requêsts_total", + expectedMetricName: "requêsts_total", }, { name: "utf8 gauge with unit 1 gets ratio suffix", @@ -433,7 +444,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeGauge, }, - expected: "cpu_usagé_ratio", + expectedMetricName: "cpu_usagé_ratio", }, { name: "utf8 metric with time unit", @@ -446,7 +457,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ms", Type: MetricTypeGauge, }, - expected: "respønse_time_milliseconds", + expectedMetricName: "respønse_time_milliseconds", + expectedUnitName: "milliseconds", }, { name: "utf8 metric with per unit", @@ -459,7 +471,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/s", Type: MetricTypeGauge, }, - expected: "requêsts_per_second", + expectedMetricName: "requêsts_per_second", + expectedUnitName: "per_second", }, { name: "utf8 metric with namespace and suffixes", @@ -473,7 +486,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/s", Type: MetricTypeMonotonicCounter, }, - expected: "ñamespace_requêsts_per_second_total", + expectedMetricName: "ñamespace_requêsts_per_second_total", + expectedUnitName: "per_second", }, { name: "metric name with only special characters", @@ -486,7 +500,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "@#$%_total", + expectedMetricName: "@#$%_total", }, { name: "namespace with special characters", @@ -500,7 +514,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeMonotonicCounter, }, - expected: "test@namespace_metric_total", + expectedMetricName: "test@namespace_metric_total", }, // Edge cases and different metric types @@ -515,7 +529,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "s", Type: MetricTypeHistogram, }, - expected: "request_duration_seconds", + expectedMetricName: "request_duration_seconds", + expectedUnitName: "seconds", }, { name: "exponential histogram metric type", @@ -528,7 +543,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "By", Type: MetricTypeExponentialHistogram, }, - expected: "request_size_bytes", + expectedMetricName: "request_size_bytes", + expectedUnitName: "bytes", }, { name: "summary metric type", @@ -541,7 +557,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ms", Type: MetricTypeSummary, }, - expected: "response_time_milliseconds", + expectedMetricName: "response_time_milliseconds", + expectedUnitName: "milliseconds", }, { name: "non-monotonic counter metric type", @@ -554,7 +571,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeNonMonotonicCounter, }, - expected: "active_connections", + expectedMetricName: "active_connections", }, { name: "unknown metric type", @@ -567,7 +584,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "", Type: MetricTypeUnknown, }, - expected: "unknown_metric", + expectedMetricName: "unknown_metric", }, // Additional unit mapping tests @@ -582,7 +599,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "d", Type: MetricTypeGauge, }, - expected: "uptime_days", + expectedMetricName: "uptime_days", + expectedUnitName: "days", }, { name: "metric with hours unit", @@ -595,7 +613,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "h", Type: MetricTypeGauge, }, - expected: "duration_hours", + expectedMetricName: "duration_hours", + expectedUnitName: "hours", }, { name: "metric with minutes unit", @@ -608,7 +627,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "min", Type: MetricTypeGauge, }, - expected: "timeout_minutes", + expectedMetricName: "timeout_minutes", + expectedUnitName: "minutes", }, { name: "metric with microseconds unit", @@ -621,7 +641,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "us", Type: MetricTypeGauge, }, - expected: "latency_microseconds", + expectedMetricName: "latency_microseconds", + expectedUnitName: "microseconds", }, { name: "metric with nanoseconds unit", @@ -634,7 +655,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ns", Type: MetricTypeGauge, }, - expected: "precision_time_nanoseconds", + expectedMetricName: "precision_time_nanoseconds", + expectedUnitName: "nanoseconds", }, { name: "metric with kibibytes unit", @@ -647,7 +669,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "KiBy", Type: MetricTypeGauge, }, - expected: "cache_size_kibibytes", + expectedMetricName: "cache_size_kibibytes", + expectedUnitName: "kibibytes", }, { name: "metric with mebibytes unit", @@ -660,7 +683,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "MiBy", Type: MetricTypeGauge, }, - expected: "memory_mebibytes", + expectedMetricName: "memory_mebibytes", + expectedUnitName: "mebibytes", }, { name: "metric with gibibytes unit", @@ -673,7 +697,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "GiBy", Type: MetricTypeGauge, }, - expected: "storage_gibibytes", + expectedMetricName: "storage_gibibytes", + expectedUnitName: "gibibytes", }, { name: "metric with tibibytes unit", @@ -686,7 +711,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "TiBy", Type: MetricTypeGauge, }, - expected: "capacity_tibibytes", + expectedMetricName: "capacity_tibibytes", + expectedUnitName: "tibibytes", }, { name: "metric with kilobytes unit", @@ -699,7 +725,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "KBy", Type: MetricTypeGauge, }, - expected: "transfer_kilobytes", + expectedMetricName: "transfer_kilobytes", + expectedUnitName: "kilobytes", }, { name: "metric with megabytes unit", @@ -712,7 +739,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "MBy", Type: MetricTypeGauge, }, - expected: "download_megabytes", + expectedMetricName: "download_megabytes", + expectedUnitName: "megabytes", }, { name: "metric with gigabytes unit", @@ -725,7 +753,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "GBy", Type: MetricTypeGauge, }, - expected: "backup_gigabytes", + expectedMetricName: "backup_gigabytes", + expectedUnitName: "gigabytes", }, { name: "metric with terabytes unit", @@ -738,7 +767,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "TBy", Type: MetricTypeGauge, }, - expected: "archive_terabytes", + expectedMetricName: "archive_terabytes", + expectedUnitName: "terabytes", }, { name: "metric with meters unit", @@ -751,7 +781,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "m", Type: MetricTypeGauge, }, - expected: "distance_meters", + expectedMetricName: "distance_meters", + expectedUnitName: "meters", }, { name: "metric with volts unit", @@ -764,7 +795,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "V", Type: MetricTypeGauge, }, - expected: "voltage_volts", + expectedMetricName: "voltage_volts", + expectedUnitName: "volts", }, { name: "metric with amperes unit", @@ -777,7 +809,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "A", Type: MetricTypeGauge, }, - expected: "current_amperes", + expectedMetricName: "current_amperes", + expectedUnitName: "amperes", }, { name: "metric with joules unit", @@ -790,7 +823,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "J", Type: MetricTypeGauge, }, - expected: "energy_joules", + expectedMetricName: "energy_joules", + expectedUnitName: "joules", }, { name: "metric with watts unit", @@ -803,7 +837,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "W", Type: MetricTypeGauge, }, - expected: "power_watts", + expectedMetricName: "power_watts", + expectedUnitName: "watts", }, { name: "metric with grams unit", @@ -816,7 +851,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "g", Type: MetricTypeGauge, }, - expected: "weight_grams", + expectedMetricName: "weight_grams", + expectedUnitName: "grams", }, { name: "metric with celsius unit", @@ -829,7 +865,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "Cel", Type: MetricTypeGauge, }, - expected: "temperature_celsius", + expectedMetricName: "temperature_celsius", + expectedUnitName: "celsius", }, { name: "metric with hertz unit", @@ -842,7 +879,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "Hz", Type: MetricTypeGauge, }, - expected: "frequency_hertz", + expectedMetricName: "frequency_hertz", + expectedUnitName: "hertz", }, { name: "metric with percent unit", @@ -855,7 +893,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "%", Type: MetricTypeGauge, }, - expected: "cpu_usage_percent", + expectedMetricName: "cpu_usage_percent", + expectedUnitName: "percent", }, // Per unit mapping tests @@ -870,7 +909,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/m", Type: MetricTypeGauge, }, - expected: "requests_per_minute", + expectedMetricName: "requests_per_minute", + expectedUnitName: "per_minute", }, { name: "metric with per hour unit", @@ -883,7 +923,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/h", Type: MetricTypeGauge, }, - expected: "events_per_hour", + expectedMetricName: "events_per_hour", + expectedUnitName: "per_hour", }, { name: "metric with per day unit", @@ -896,7 +937,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/d", Type: MetricTypeGauge, }, - expected: "transactions_per_day", + expectedMetricName: "transactions_per_day", + expectedUnitName: "per_day", }, { name: "metric with per week unit", @@ -909,7 +951,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/w", Type: MetricTypeGauge, }, - expected: "reports_per_week", + expectedMetricName: "reports_per_week", + expectedUnitName: "per_week", }, { name: "metric with per month unit", @@ -922,7 +965,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/mo", Type: MetricTypeGauge, }, - expected: "invoices_per_month", + expectedMetricName: "invoices_per_month", + expectedUnitName: "per_month", }, { name: "metric with per year unit", @@ -935,7 +979,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/y", Type: MetricTypeGauge, }, - expected: "renewals_per_year", + expectedMetricName: "renewals_per_year", + expectedUnitName: "per_year", }, { name: "metric with unknown per unit", @@ -948,7 +993,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1/custom_unit", Type: MetricTypeGauge, }, - expected: "custom_per_custom_unit", + expectedMetricName: "custom_per_custom_unit", + expectedUnitName: "per_custom_unit", }, // Edge cases with empty and whitespace units @@ -963,7 +1009,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "By/", Type: MetricTypeGauge, }, - expected: "metric_bytes", + expectedMetricName: "metric_bytes", + expectedUnitName: "bytes", }, { name: "metric with whitespace in unit", @@ -976,7 +1023,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: " By / s ", Type: MetricTypeGauge, }, - expected: "metric_bytes_per_second", + expectedMetricName: "metric_bytes_per_second", + expectedUnitName: "bytes_per_second", }, { name: "metric with only slash in unit", @@ -989,7 +1037,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "/", Type: MetricTypeGauge, }, - expected: "metric", + expectedMetricName: "metric", }, // Common OTel metrics to showcase how the namer works @@ -1004,7 +1052,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ms", Type: MetricTypeHistogram, }, - expected: "http_request_duration_milliseconds", + expectedMetricName: "http_request_duration_milliseconds", + expectedUnitName: "milliseconds", }, { name: "http.request.duration/OTel-style", @@ -1017,7 +1066,8 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "ms", Type: MetricTypeHistogram, }, - expected: "http.request.duration", + expectedMetricName: "http.request.duration", + expectedUnitName: "milliseconds", }, { name: "http.requests/Prometheus-style", @@ -1030,7 +1080,7 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeMonotonicCounter, }, - expected: "http_requests_total", + expectedMetricName: "http_requests_total", }, { name: "http.requests/OTel-style", @@ -1043,14 +1093,32 @@ func TestMetricNamer_Build(t *testing.T) { Unit: "1", Type: MetricTypeMonotonicCounter, }, - expected: "http.requests", + expectedMetricName: "http.requests", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.namer.Build(tt.metric) - require.Equal(t, tt.expected, got) + // Build metric name using MetricNamer + gotMetricName := tt.namer.Build(tt.metric) + require.Equal(t, tt.expectedMetricName, gotMetricName) + + // Build unit name using UnitNamer to verify correlation when suffixes are enabled + if tt.namer.WithMetricSuffixes { + unitNamer := UnitNamer{ + UTF8Allowed: tt.namer.UTF8Allowed, + } + gotUnitName := unitNamer.Build(tt.metric.Unit) + require.Equal(t, tt.expectedUnitName, gotUnitName) + + // Verify correlation: if UnitNamer produces a non-empty unit name, + // it should be contained in the metric name when WithMetricSuffixes=true + if gotUnitName != "" && tt.namer.WithMetricSuffixes { + require.Contains(t, gotMetricName, gotUnitName, + "Metric name '%s' should contain unit name '%s' when WithMetricSuffixes=true", + gotMetricName, gotUnitName) + } + } }) } } diff --git a/unit_namer.go b/unit_namer.go new file mode 100644 index 0000000..4bbf93e --- /dev/null +++ b/unit_namer.go @@ -0,0 +1,110 @@ +// Copyright 2025 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 + +package otlptranslator + +import "strings" + +// UnitNamer is a helper for building compliant unit names. +type UnitNamer struct { + UTF8Allowed bool +} + +// Build builds a unit name for the specified unit string. +// It processes the unit by splitting it into main and per components, +// applying appropriate unit mappings, and cleaning up invalid characters +// when the whole UTF-8 character set is not allowed. +func (un *UnitNamer) Build(unit string) string { + mainUnit, perUnit := buildUnitSuffixes(unit) + if !un.UTF8Allowed { + mainUnit, perUnit = cleanUpUnit(mainUnit), cleanUpUnit(perUnit) + } + + var u string + switch { + case mainUnit != "" && perUnit != "": + u = mainUnit + "_" + perUnit + case mainUnit != "": + u = mainUnit + default: + u = perUnit + } + + // Clean up leading and trailing underscores + if len(u) > 0 && u[0:1] == "_" { + u = u[1:] + } + if len(u) > 0 && u[len(u)-1:] == "_" { + u = u[:len(u)-1] + } + + return u +} + +// 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 { + if promUnit, ok := unitMap[unit]; ok { + return promUnit + } + return unit +} + +// Retrieve the Prometheus "per" unit corresponding to the specified "per" unit. +// Returns the specified unit if not found in perUnitMap. +func perUnitMapGetOrDefault(perUnit string) string { + if promPerUnit, ok := perUnitMap[perUnit]; ok { + return promPerUnit + } + return perUnit +} + +// 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) { + // Split unit at the '/' if any + unitTokens := strings.SplitN(unit, "/", 2) + + if len(unitTokens) > 0 { + // Main unit + // Update if not blank and doesn't contain '{}' + mainUnitOTel := strings.TrimSpace(unitTokens[0]) + if mainUnitOTel != "" && !strings.ContainsAny(mainUnitOTel, "{}") { + mainUnitSuffix = unitMapGetOrDefault(mainUnitOTel) + } + + // Per unit + // Update if not blank and doesn't contain '{}' + if len(unitTokens) > 1 && unitTokens[1] != "" { + perUnitOTel := strings.TrimSpace(unitTokens[1]) + if perUnitOTel != "" && !strings.ContainsAny(perUnitOTel, "{}") { + perUnitSuffix = perUnitMapGetOrDefault(perUnitOTel) + } + if perUnitSuffix != "" { + perUnitSuffix = "per_" + perUnitSuffix + } + } + } + + return mainUnitSuffix, perUnitSuffix +} + +// cleanUpUnit cleans up unit so it matches model.LabelNameRE. +func cleanUpUnit(unit string) string { + // Multiple consecutive underscores are replaced 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. + return strings.TrimPrefix(multipleUnderscoresRE.ReplaceAllString( + strings.Map(replaceInvalidMetricChar, unit), + "_", + ), "_") +}