diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index ca8b58b92a2..036123a75a9 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -10,17 +10,6 @@ namespace OpenTelemetry.Exporter.Prometheus; internal sealed class PrometheusMetric { - /* Counter becomes counter - Gauge becomes gauge - Histogram becomes histogram - UpDownCounter becomes gauge - * https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/data-model.md#otlp-metric-points-to-prometheus - */ - private static readonly PrometheusType[] MetricTypes = - [ - PrometheusType.Untyped, PrometheusType.Counter, PrometheusType.Gauge, PrometheusType.Summary, PrometheusType.Histogram, PrometheusType.Histogram, PrometheusType.Histogram, PrometheusType.Histogram, PrometheusType.Gauge, - ]; - public PrometheusMetric(string name, string unit, PrometheusType type, bool disableTotalNameSuffixForCounters) { // The metric name is @@ -87,9 +76,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa public PrometheusType Type { get; } public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters) - { - return new PrometheusMetric(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters); - } + => new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters); internal static string SanitizeMetricName(string metricName) { @@ -180,19 +167,28 @@ internal static string RemoveAnnotations(string unit) internal static PrometheusType GetPrometheusType(MetricType openTelemetryMetricType) { int metricType = (int)openTelemetryMetricType >> 4; - return MetricTypes[metricType]; - } - private static string SanitizeOpenMetricsName(string metricName) - { - if (metricName.EndsWith("_total", StringComparison.Ordinal)) + /* Counter becomes counter + Gauge becomes gauge + Histogram becomes histogram + UpDownCounter becomes gauge + * https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/data-model.md#otlp-metric-points-to-prometheus + */ + return metricType switch { - return metricName.Substring(0, metricName.Length - 6); - } - - return metricName; + 0 => PrometheusType.Untyped, + 1 => PrometheusType.Counter, + 2 => PrometheusType.Gauge, + 3 => PrometheusType.Summary, + 4 or 5 or 6 or 7 => PrometheusType.Histogram, + 8 => PrometheusType.Gauge, + _ => throw new InvalidOperationException($"Invalid {nameof(MetricType)} value."), + }; } + private static string SanitizeOpenMetricsName(string metricName) + => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; + private static string GetUnit(string unit) { // Dropping the portions of the Unit within brackets (e.g. {packet}). Brackets MUST NOT be included in the resulting unit. A "count of foo" is considered unitless in Prometheus. @@ -242,67 +238,61 @@ private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)] // (See also https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/metrics.md#instrument-units) // Prometheus best practices for units: https://prometheus.io/docs/practices/naming/#base-units // OpenMetrics specification for units: https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#units-and-base-units - private static string MapUnit(ReadOnlySpan unit) + private static string MapUnit(ReadOnlySpan unit) => unit switch { - return unit switch - { - // Time - "d" => "days", - "h" => "hours", - "min" => "minutes", - "s" => "seconds", - "ms" => "milliseconds", - "us" => "microseconds", - "ns" => "nanoseconds", - - // Bytes - "By" => "bytes", - "KiBy" => "kibibytes", - "MiBy" => "mebibytes", - "GiBy" => "gibibytes", - "TiBy" => "tibibytes", - "KBy" => "kilobytes", - "MBy" => "megabytes", - "GBy" => "gigabytes", - "TBy" => "terabytes", - "B" => "bytes", - "KB" => "kilobytes", - "MB" => "megabytes", - "GB" => "gigabytes", - "TB" => "terabytes", - - // SI - "m" => "meters", - "V" => "volts", - "A" => "amperes", - "J" => "joules", - "W" => "watts", - "g" => "grams", - - // Misc - "Cel" => "celsius", - "Hz" => "hertz", - "1" => string.Empty, - "%" => "percent", - "$" => "dollars", - _ => unit.ToString(), - }; - } + // Time + "d" => "days", + "h" => "hours", + "min" => "minutes", + "s" => "seconds", + "ms" => "milliseconds", + "us" => "microseconds", + "ns" => "nanoseconds", + + // Bytes + "By" => "bytes", + "KiBy" => "kibibytes", + "MiBy" => "mebibytes", + "GiBy" => "gibibytes", + "TiBy" => "tibibytes", + "KBy" => "kilobytes", + "MBy" => "megabytes", + "GBy" => "gigabytes", + "TBy" => "terabytes", + "B" => "bytes", + "KB" => "kilobytes", + "MB" => "megabytes", + "GB" => "gigabytes", + "TB" => "terabytes", + + // SI + "m" => "meters", + "V" => "volts", + "A" => "amperes", + "J" => "joules", + "W" => "watts", + "g" => "grams", + + // Misc + "Cel" => "celsius", + "Hz" => "hertz", + "1" => string.Empty, + "%" => "percent", + "$" => "dollars", + _ => unit.ToString(), + }; // The map that translates the "per" unit // Example: s => per second (singular) - private static string MapPerUnit(ReadOnlySpan perUnit) + private static string MapPerUnit(ReadOnlySpan perUnit) => perUnit switch { - return perUnit switch - { - "s" => "second", - "m" => "minute", - "h" => "hour", - "d" => "day", - "w" => "week", - "mo" => "month", - "y" => "year", - _ => perUnit.ToString(), - }; - } + "s" => "second", + "m" => "minute", + "h" => "hour", + "d" => "day", + "w" => "week", + "mo" => "month", + "y" => "year", + _ => perUnit.ToString(), + }; } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index 6ef3e0c13e3..dbf996173c1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -272,6 +272,231 @@ public void GetPrometheusType_MapsOpenTelemetryMetricsTypeToPrometheus(MetricsMa Assert.Equal(mappingTestData.ExpectedPrometheusType, result); } + [Theory] + [InlineData("d", "days")] + [InlineData("h", "hours")] + [InlineData("min", "minutes")] + [InlineData("s", "seconds")] + [InlineData("ms", "milliseconds")] + [InlineData("us", "microseconds")] + [InlineData("ns", "nanoseconds")] + public void Name_TimeUnits_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Theory] + [InlineData("By", "bytes")] + [InlineData("KiBy", "kibibytes")] + [InlineData("MiBy", "mebibytes")] + [InlineData("GiBy", "gibibytes")] + [InlineData("TiBy", "tibibytes")] + [InlineData("KBy", "kilobytes")] + [InlineData("MBy", "megabytes")] + [InlineData("GBy", "gigabytes")] + [InlineData("TBy", "terabytes")] + [InlineData("B", "bytes")] + [InlineData("KB", "kilobytes")] + [InlineData("MB", "megabytes")] + [InlineData("GB", "gigabytes")] + [InlineData("TB", "terabytes")] + public void Name_ByteUnits_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Theory] + [InlineData("m", "meters")] + [InlineData("V", "volts")] + [InlineData("A", "amperes")] + [InlineData("J", "joules")] + [InlineData("W", "watts")] + [InlineData("g", "grams")] + public void Name_SIUnits_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Theory] + [InlineData("Cel", "celsius")] + [InlineData("Hz", "hertz")] + [InlineData("%", "percent")] + [InlineData("$", "dollars")] + public void Name_MiscUnits_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Fact] + public void Name_UnknownUnit_UsedAsIs() + { + AssertName("metric", "custom_unit", PrometheusType.Gauge, false, "metric_custom_unit"); + } + + [Theory] + [InlineData("requests/s", "requests_per_second")] + [InlineData("bits/s", "bits_per_second")] + [InlineData("errors/m", "errors_per_minute")] + [InlineData("events/h", "events_per_hour")] + [InlineData("calls/d", "calls_per_day")] + [InlineData("tasks/w", "tasks_per_week")] + [InlineData("jobs/mo", "jobs_per_month")] + [InlineData("cycles/y", "cycles_per_year")] + public void Name_RateUnits_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Theory] + [InlineData("By/s", "bytes_per_second")] + [InlineData("ms/m", "milliseconds_per_minute")] + [InlineData("%/h", "percent_per_hour")] + public void Name_RateUnitsWithMapping_MappedCorrectly(string unit, string expectedUnit) + { + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Fact] + public void Name_UnitWithAnnotations_AnnotationsRemoved() + { + AssertName("metric", "{packet}By", PrometheusType.Gauge, false, "metric_bytes"); + } + + [Fact] + public void Name_ComplexUnitWithAnnotations_AnnotationsRemoved() + { + AssertName("metric", "{CPU}%{usage}", PrometheusType.Gauge, false, "metric_percent"); + } + + [Fact] + public void Name_EmptyUnit_NoSuffixAdded() + { + AssertName("metric", string.Empty, PrometheusType.Gauge, false, "metric"); + } + + [Fact] + public void Name_NullUnit_NoSuffixAdded() + { + var prometheusMetric = new PrometheusMetric("metric", null!, PrometheusType.Gauge, false); + Assert.Equal("metric", prometheusMetric.Name); + } + + [Fact] + public void Constructor_VerifiesAllProperties() + { + var metric = new PrometheusMetric("test_metric", "By", PrometheusType.Counter, false); + + Assert.Equal("test_metric_bytes_total", metric.Name); + Assert.Equal("test_metric_bytes_total", metric.OpenMetricsName); + Assert.Equal("test_metric_bytes", metric.OpenMetricsMetadataName); + Assert.Equal("bytes", metric.Unit); + Assert.Equal(PrometheusType.Counter, metric.Type); + } + + [Theory] + [InlineData("requests/custom", "requests_per_custom")] + [InlineData("events/quarter", "events_per_quarter")] + [InlineData("packets/tick", "packets_per_tick")] + [InlineData("items/unknown_unit", "items_per_unknown_unit")] + public void Name_RateUnitsWithUnknownPerUnit_UsedAsIs(string unit, string expectedUnit) + { + // Per units not in the known list (s, m, h, d, w, mo, y) should be used as-is + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Theory] + [InlineData("By/custom", "bytes_per_custom")] + [InlineData("%/unknown", "percent_per_unknown")] + public void Name_RateUnitsWithMappedNumeratorAndUnknownDenominator_MapsCorrectly(string unit, string expectedUnit) + { + // Numerator should be mapped, denominator used as-is if unknown + AssertName("metric", unit, PrometheusType.Gauge, false, $"metric_{expectedUnit}"); + } + + [Fact] + public void Name_UntypedMetricType_WorksCorrectly() + { + var metric = new PrometheusMetric("metric", "s", PrometheusType.Untyped, false); + + Assert.Equal("metric_seconds", metric.Name); + Assert.Equal(PrometheusType.Untyped, metric.Type); + } + + [Fact] + public void Name_SummaryMetricType_WorksCorrectly() + { + var metric = new PrometheusMetric("latency", "ms", PrometheusType.Summary, false); + + Assert.Equal("latency_milliseconds", metric.Name); + Assert.Equal(PrometheusType.Summary, metric.Type); + } + + [Fact] + public void Name_SummaryWithTotal_DoesNotAppendTotal() + { + // Summary metrics should not have _total appended even if not already present + AssertName("requests", "1", PrometheusType.Summary, false, "requests"); + } + + [Fact] + public void GetPrometheusType_Summary_ReturnsSummary() + { + // MetricType enum value that maps to Summary (case 3 in switch) + var result = PrometheusMetric.GetPrometheusType((MetricType)0x30); + Assert.Equal(PrometheusType.Summary, result); + } + + [Fact] + public void GetPrometheusType_Untyped_ReturnsUntyped() + { + // MetricType enum value that maps to Untyped (case 0 in switch) + var result = PrometheusMetric.GetPrometheusType((MetricType)0x00); + Assert.Equal(PrometheusType.Untyped, result); + } + + [Fact] + public void GetPrometheusType_InvalidMetricType_ThrowsInvalidOperationException() + { + // Test default case in switch statement - invalid MetricType value + var invalidMetricType = (MetricType)0xFF; + + var exception = Assert.Throws(() => + PrometheusMetric.GetPrometheusType(invalidMetricType)); + + Assert.Contains("Invalid", exception.Message, StringComparison.Ordinal); + Assert.Contains("MetricType", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(0x40)] + [InlineData(0x50)] + [InlineData(0x60)] + [InlineData(0x70)] + public void GetPrometheusType_HistogramVariants_ReturnsHistogram(int metricTypeValue) + { + var result = PrometheusMetric.GetPrometheusType((MetricType)metricTypeValue); + Assert.Equal(PrometheusType.Histogram, result); + } + + [Fact] + public void Name_MultipleSlashesInUnit_FirstSlashProcessed() + { + // Multiple slashes + AssertName("metric", "req/s/extra", PrometheusType.Gauge, false, "metric_req_per_s/extra"); + } + + [Theory] + [InlineData(PrometheusType.Counter)] + [InlineData(PrometheusType.Gauge)] + [InlineData(PrometheusType.Histogram)] + [InlineData(PrometheusType.Summary)] + [InlineData(PrometheusType.Untyped)] + internal void Constructor_AllPrometheusTypes_Work(PrometheusType type) + { + var metric = new PrometheusMetric("metric", "s", type, false); + Assert.Equal(type, metric.Type); + } + private static void AssertName( string name, string unit, PrometheusType type, bool disableTotalNameSuffixForCounters, string expected) {