From 49031ba208d27082fad360c8d2710f2d1ea05659 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 17 Aug 2023 01:53:05 +0800 Subject: [PATCH 1/2] Update Prometheus exporter name and unit processing (#4753) Co-authored-by: Utkarsh Umesan Pillai <66651184+utpilla@users.noreply.github.com> Co-authored-by: Mikel Blanchard --- .../CHANGELOG.md | 3 + ...etry.Exporter.Prometheus.AspNetCore.csproj | 2 + .../CHANGELOG.md | 3 + .../Internal/PrometheusCollectionManager.cs | 30 +- .../Internal/PrometheusMetric.cs | 293 ++++++++++++++++++ .../Internal/PrometheusSerializer.cs | 81 ++--- .../Internal/PrometheusSerializerExt.cs | 35 +-- .../Internal/PrometheusType.cs | 45 +++ .../PrometheusSerializerBenchmarks.cs | 14 +- .../PrometheusExporterMiddlewareTests.cs | 4 +- .../PrometheusHttpListenerTests.cs | 2 +- .../PrometheusMetricTests.cs | 220 +++++++++++++ .../PrometheusSerializerTests.cs | 40 +-- 13 files changed, 676 insertions(+), 96 deletions(-) create mode 100644 src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs create mode 100644 src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusType.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index d75871cdbe0..33653049bbc 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +* Added support for unit and name conversion following the [OpenTelemetry Specification](https://github.com/open-telemetry/opentelemetry-specification/blob/065b25024549120800da7cda6ccd9717658ff0df/specification/compatibility/prometheus_and_openmetrics.md?plain=1#L235-L240) + ([#4753](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4753)) + ## 1.6.0-alpha.1 Released 2023-Jul-12 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj index 7fa02cfdb93..9471eeda251 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj @@ -26,6 +26,8 @@ + + diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index a7a8d3b09c9..df46fc8dc84 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +* Added support for unit and name conversion following the [OpenTelemetry Specification](https://github.com/open-telemetry/opentelemetry-specification/blob/065b25024549120800da7cda6ccd9717658ff0df/specification/compatibility/prometheus_and_openmetrics.md?plain=1#L235-L240) + ([#4753](https://github.com/open-telemetry/opentelemetry-dotnet/pull/4753)) + ## 1.6.0-alpha.1 Released 2023-Jul-12 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 4ecef3aaa11..4d4ef30eda1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -21,9 +21,13 @@ namespace OpenTelemetry.Exporter.Prometheus; internal sealed class PrometheusCollectionManager { + private const int MaxCachedMetrics = 1024; + private readonly PrometheusExporter exporter; private readonly int scrapeResponseCacheDurationMilliseconds; private readonly Func, ExportResult> onCollectRef; + private readonly Dictionary metricsCache; + private int metricsCacheCount; private byte[] buffer = new byte[85000]; // encourage the object to live in LOH (large object heap) private int globalLockState; private ArraySegment previousDataView; @@ -37,6 +41,7 @@ public PrometheusCollectionManager(PrometheusExporter exporter) this.exporter = exporter; this.scrapeResponseCacheDurationMilliseconds = this.exporter.ScrapeResponseCacheDurationMilliseconds; this.onCollectRef = this.OnCollect; + this.metricsCache = new Dictionary(); } #if NET6_0_OR_GREATER @@ -179,11 +184,16 @@ private ExportResult OnCollect(Batch metrics) { foreach (var metric in metrics) { + if (!PrometheusSerializer.CanWriteMetric(metric)) + { + continue; + } + while (true) { try { - cursor = PrometheusSerializer.WriteMetric(this.buffer, cursor, metric); + cursor = PrometheusSerializer.WriteMetric(this.buffer, cursor, metric, this.GetPrometheusMetric(metric)); break; } catch (IndexOutOfRangeException) @@ -244,6 +254,24 @@ private bool IncreaseBufferSize() return true; } + private PrometheusMetric GetPrometheusMetric(Metric metric) + { + // Optimize writing metrics with bounded cache that has pre-calculated Prometheus names. + if (!this.metricsCache.TryGetValue(metric, out var prometheusMetric)) + { + prometheusMetric = PrometheusMetric.Create(metric); + + // Add to the cache if there is space. + if (this.metricsCacheCount < MaxCachedMetrics) + { + this.metricsCache[metric] = prometheusMetric; + this.metricsCacheCount++; + } + } + + return prometheusMetric; + } + public readonly struct CollectionResponse { public CollectionResponse(ArraySegment view, DateTime generatedAtUtc, bool fromCache) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs new file mode 100644 index 00000000000..2c500562fed --- /dev/null +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -0,0 +1,293 @@ +// +// Copyright The OpenTelemetry 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 +// limitations under the License. +// + +using System.Text; +using OpenTelemetry.Metrics; + +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 = new PrometheusType[] + { + 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) + { + // The metric name is + // required to match the regex: `[a-zA-Z_:]([a-zA-Z0-9_:])*`. Invalid characters + // in the metric name MUST be replaced with the `_` character. Multiple + // consecutive `_` characters MUST be replaced with a single `_` character. + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L230-L233 + var sanitizedName = SanitizeMetricName(name); + + string sanitizedUnit = null; + if (!string.IsNullOrEmpty(unit)) + { + sanitizedUnit = GetUnit(unit); + + // The resulting unit SHOULD be added to the metric as + // [OpenMetrics UNIT metadata](https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#metricfamily) + // and as a suffix to the metric name unless the metric name already contains the + // unit, or the unit MUST be omitted. The unit suffix comes before any + // type-specific suffixes. + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L242-L246 + if (!sanitizedName.Contains(sanitizedUnit)) + { + sanitizedName = sanitizedName + "_" + sanitizedUnit; + } + } + + // If the metric name for monotonic Sum metric points does not end in a suffix of `_total` a suffix of `_total` MUST be added by default, otherwise the name MUST remain unchanged. + // Exporters SHOULD provide a configuration option to disable the addition of `_total` suffixes. + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L286 + if (type == PrometheusType.Counter && !sanitizedName.EndsWith("_total")) + { + sanitizedName += "_total"; + } + + // Special case: Converting "1" to "ratio". + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L239 + if (type == PrometheusType.Gauge && unit == "1" && !sanitizedName.Contains("ratio")) + { + sanitizedName += "_ratio"; + } + + this.Name = sanitizedName; + this.Unit = sanitizedUnit; + this.Type = type; + } + + public string Name { get; } + + public string Unit { get; } + + public PrometheusType Type { get; } + + public static PrometheusMetric Create(Metric metric) + { + return new PrometheusMetric(metric.Name, metric.Unit, GetPrometheusType(metric)); + } + + internal static string SanitizeMetricName(string metricName) + { + StringBuilder sb = null; + var lastCharUnderscore = false; + + for (var i = 0; i < metricName.Length; i++) + { + var c = metricName[i]; + + if (i == 0 && char.IsNumber(c)) + { + sb ??= CreateStringBuilder(metricName); + sb.Append('_'); + lastCharUnderscore = true; + continue; + } + + if (!char.IsLetterOrDigit(c) && c != ':') + { + if (!lastCharUnderscore) + { + lastCharUnderscore = true; + sb ??= CreateStringBuilder(metricName); + sb.Append('_'); + } + } + else + { + sb ??= CreateStringBuilder(metricName); + sb.Append(c); + lastCharUnderscore = false; + } + } + + return sb?.ToString() ?? metricName; + + static StringBuilder CreateStringBuilder(string name) => new StringBuilder(name.Length); + } + + internal static string RemoveAnnotations(string unit) + { + // UCUM standard says the curly braces shouldn't be nested: + // https://ucum.org/ucum#section-Character-Set-and-Lexical-Rules + // What should happen if they are nested isn't defined. + // Right now the remove annotations code doesn't attempt to balance multiple start and end braces. + StringBuilder sb = null; + + var hasOpenBrace = false; + var startOpenBraceIndex = 0; + var lastWriteIndex = 0; + + for (var i = 0; i < unit.Length; i++) + { + var c = unit[i]; + if (c == '{') + { + if (!hasOpenBrace) + { + hasOpenBrace = true; + startOpenBraceIndex = i; + } + } + else if (c == '}') + { + if (hasOpenBrace) + { + sb ??= new StringBuilder(); + sb.Append(unit, lastWriteIndex, startOpenBraceIndex - lastWriteIndex); + hasOpenBrace = false; + lastWriteIndex = i + 1; + } + } + } + + if (lastWriteIndex == 0) + { + return unit; + } + + sb.Append(unit, lastWriteIndex, unit.Length - lastWriteIndex); + return sb.ToString(); + } + + 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. + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L238 + var updatedUnit = RemoveAnnotations(unit); + + // Converting "foo/bar" to "foo_per_bar". + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L240C3-L240C41 + if (TryProcessRateUnits(updatedUnit, out var updatedPerUnit)) + { + updatedUnit = updatedPerUnit; + } + else + { + // Converting from abbreviations to full words (e.g. "ms" to "milliseconds"). + // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L237 + updatedUnit = MapUnit(updatedUnit.AsSpan()); + } + + return updatedUnit; + } + + private static bool TryProcessRateUnits(string updatedUnit, out string updatedPerUnit) + { + updatedPerUnit = null; + + for (int i = 0; i < updatedUnit.Length; i++) + { + if (updatedUnit[i] == '/') + { + // Only convert rate expressed units if it's a valid expression. + if (i == updatedUnit.Length - 1) + { + return false; + } + + updatedPerUnit = MapUnit(updatedUnit.AsSpan(0, i)) + "_per_" + MapPerUnit(updatedUnit.AsSpan(i + 1, updatedUnit.Length - i - 1)); + return true; + } + } + + return false; + } + + private static PrometheusType GetPrometheusType(Metric metric) + { + int metricType = (int)metric.MetricType >> 4; + return MetricTypes[metricType]; + } + + // The map to translate OTLP units to Prometheus units + // OTLP metrics use the c/s notation as specified at https://ucum.org/ucum.html + // (See also https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/README.md#instrument-units) + // Prometheus best practices for units: https://prometheus.io/docs/practices/naming/#base-units + // OpenMetrics specification for units: https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#units-and-base-units + private static string MapUnit(ReadOnlySpan unit) + { + 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(), + }; + } + + // The map that translates the "per" unit + // Example: s => per second (singular) + private static string MapPerUnit(ReadOnlySpan perUnit) + { + return perUnit switch + { + "s" => "second", + "m" => "minute", + "h" => "hour", + "d" => "day", + "w" => "week", + "mo" => "month", + "y" => "year", + _ => perUnit.ToString(), + }; + } +} diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 611da872b64..1ca91a5ee7f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -229,39 +229,13 @@ public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteMetricName(byte[] buffer, int cursor, string metricName, string metricUnit = null) + public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric) { - Debug.Assert(!string.IsNullOrEmpty(metricName), $"{nameof(metricName)} should not be null or empty."); - - for (int i = 0; i < metricName.Length; i++) + // Metric name has already been escaped. + for (int i = 0; i < metric.Name.Length; i++) { - var ordinal = (ushort)metricName[i]; - buffer[cursor++] = ordinal switch - { - ASCII_FULL_STOP or ASCII_HYPHEN_MINUS => unchecked((byte)'_'), - _ => unchecked((byte)ordinal), - }; - } - - if (!string.IsNullOrEmpty(metricUnit)) - { - buffer[cursor++] = unchecked((byte)'_'); - - for (int i = 0; i < metricUnit.Length; i++) - { - var ordinal = (ushort)metricUnit[i]; - - if ((ordinal >= (ushort)'A' && ordinal <= (ushort)'Z') || - (ordinal >= (ushort)'a' && ordinal <= (ushort)'z') || - (ordinal >= (ushort)'0' && ordinal <= (ushort)'9')) - { - buffer[cursor++] = unchecked((byte)ordinal); - } - else - { - buffer[cursor++] = unchecked((byte)'_'); - } - } + var ordinal = (ushort)metric.Name[i]; + buffer[cursor++] = unchecked((byte)ordinal); } return cursor; @@ -277,7 +251,7 @@ public static int WriteEof(byte[] buffer, int cursor) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteHelpMetadata(byte[] buffer, int cursor, string metricName, string metricUnit, string metricDescription) + public static int WriteHelpMetadata(byte[] buffer, int cursor, PrometheusMetric metric, string metricDescription) { if (string.IsNullOrEmpty(metricDescription)) { @@ -285,7 +259,7 @@ public static int WriteHelpMetadata(byte[] buffer, int cursor, string metricName } cursor = WriteAsciiStringNoEscape(buffer, cursor, "# HELP "); - cursor = WriteMetricName(buffer, cursor, metricName, metricUnit); + cursor = WriteMetricName(buffer, cursor, metric); if (!string.IsNullOrEmpty(metricDescription)) { @@ -299,12 +273,14 @@ public static int WriteHelpMetadata(byte[] buffer, int cursor, string metricName } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteTypeMetadata(byte[] buffer, int cursor, string metricName, string metricUnit, string metricType) + public static int WriteTypeMetadata(byte[] buffer, int cursor, PrometheusMetric metric) { + var metricType = MapPrometheusType(metric.Type); + Debug.Assert(!string.IsNullOrEmpty(metricType), $"{nameof(metricType)} should not be null or empty."); cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE "); - cursor = WriteMetricName(buffer, cursor, metricName, metricUnit); + cursor = WriteMetricName(buffer, cursor, metric); buffer[cursor++] = unchecked((byte)' '); cursor = WriteAsciiStringNoEscape(buffer, cursor, metricType); @@ -314,36 +290,39 @@ public static int WriteTypeMetadata(byte[] buffer, int cursor, string metricName } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteUnitMetadata(byte[] buffer, int cursor, string metricName, string metricUnit) + public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric metric) { - if (string.IsNullOrEmpty(metricUnit)) + if (string.IsNullOrEmpty(metric.Unit)) { return cursor; } cursor = WriteAsciiStringNoEscape(buffer, cursor, "# UNIT "); - cursor = WriteMetricName(buffer, cursor, metricName, metricUnit); + cursor = WriteMetricName(buffer, cursor, metric); buffer[cursor++] = unchecked((byte)' '); - for (int i = 0; i < metricUnit.Length; i++) + // Unit name has already been escaped. + for (int i = 0; i < metric.Unit.Length; i++) { - var ordinal = (ushort)metricUnit[i]; - - if ((ordinal >= (ushort)'A' && ordinal <= (ushort)'Z') || - (ordinal >= (ushort)'a' && ordinal <= (ushort)'z') || - (ordinal >= (ushort)'0' && ordinal <= (ushort)'9')) - { - buffer[cursor++] = unchecked((byte)ordinal); - } - else - { - buffer[cursor++] = unchecked((byte)'_'); - } + var ordinal = (ushort)metric.Unit[i]; + buffer[cursor++] = unchecked((byte)ordinal); } buffer[cursor++] = ASCII_LINEFEED; return cursor; } + + private static string MapPrometheusType(PrometheusType type) + { + return type switch + { + PrometheusType.Gauge => "gauge", + PrometheusType.Counter => "counter", + PrometheusType.Summary => "summary", + PrometheusType.Histogram => "histogram", + _ => "untyped", + }; + } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index e1fea7093bb..0eb068b185d 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -23,30 +23,23 @@ namespace OpenTelemetry.Exporter.Prometheus; /// internal static partial class PrometheusSerializer { - /* 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 string[] MetricTypes = new string[] - { - "untyped", "counter", "gauge", "summary", "histogram", "histogram", "histogram", "histogram", "gauge", - }; - - public static int WriteMetric(byte[] buffer, int cursor, Metric metric) + public static bool CanWriteMetric(Metric metric) { if (metric.MetricType == MetricType.ExponentialHistogram) { // Exponential histograms are not yet support by Prometheus. // They are ignored for now. - return cursor; + return false; } - int metricType = (int)metric.MetricType >> 4; - cursor = WriteTypeMetadata(buffer, cursor, metric.Name, metric.Unit, MetricTypes[metricType]); - cursor = WriteUnitMetadata(buffer, cursor, metric.Name, metric.Unit); - cursor = WriteHelpMetadata(buffer, cursor, metric.Name, metric.Unit, metric.Description); + return true; + } + + public static int WriteMetric(byte[] buffer, int cursor, Metric metric, PrometheusMetric prometheusMetric) + { + cursor = WriteTypeMetadata(buffer, cursor, prometheusMetric); + cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric); + cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description); if (!metric.MetricType.IsHistogram()) { @@ -56,7 +49,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric) var timestamp = metricPoint.EndTime.ToUnixTimeMilliseconds(); // Counter and Gauge - cursor = WriteMetricName(buffer, cursor, metric.Name, metric.Unit); + cursor = WriteMetricName(buffer, cursor, prometheusMetric); if (tags.Count > 0) { @@ -118,7 +111,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric) { totalCount += histogramMeasurement.BucketCount; - cursor = WriteMetricName(buffer, cursor, metric.Name, metric.Unit); + cursor = WriteMetricName(buffer, cursor, prometheusMetric); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); foreach (var tag in tags) @@ -149,7 +142,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric) } // Histogram sum - cursor = WriteMetricName(buffer, cursor, metric.Name, metric.Unit); + cursor = WriteMetricName(buffer, cursor, prometheusMetric); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum"); if (tags.Count > 0) @@ -175,7 +168,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric) buffer[cursor++] = ASCII_LINEFEED; // Histogram count - cursor = WriteMetricName(buffer, cursor, metric.Name, metric.Unit); + cursor = WriteMetricName(buffer, cursor, prometheusMetric); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); if (tags.Count > 0) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusType.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusType.cs new file mode 100644 index 00000000000..0b5acfa8724 --- /dev/null +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusType.cs @@ -0,0 +1,45 @@ +// +// Copyright The OpenTelemetry 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 +// limitations under the License. +// + +namespace OpenTelemetry.Exporter.Prometheus; + +internal enum PrometheusType +{ + /// + /// Not mapped. + /// + Untyped, + + /// + /// Mapped from Gauge and UpDownCounter. + /// + Gauge, + + /// + /// Mapped from Counter. + /// + Counter, + + /// + /// Not mapped. + /// + Summary, + + /// + /// Mapped from Histogram. + /// + Histogram, +} diff --git a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs index 11258c8f0b7..0629593aa81 100644 --- a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs +++ b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs @@ -29,6 +29,7 @@ public class PrometheusSerializerBenchmarks private readonly byte[] buffer = new byte[85000]; private Meter meter; private MeterProvider meterProvider; + private Dictionary cache = new Dictionary(); [Params(1, 1000, 10000)] public int NumberOfSerializeCalls { get; set; } @@ -69,8 +70,19 @@ public void WriteMetric() int cursor = 0; foreach (var metric in this.metrics) { - cursor = PrometheusSerializer.WriteMetric(this.buffer, cursor, metric); + cursor = PrometheusSerializer.WriteMetric(this.buffer, cursor, metric, this.GetPrometheusMetric(metric)); } } } + + private PrometheusMetric GetPrometheusMetric(Metric metric) + { + if (!this.cache.TryGetValue(metric, out var prometheusMetric)) + { + prometheusMetric = PrometheusMetric.Create(metric); + this.cache[metric] = prometheusMetric; + } + + return prometheusMetric; + } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 171b26500ee..c863b08ed78 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -298,8 +298,8 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( var matches = Regex.Matches( content, ("^" - + "# TYPE counter_double counter\n" - + "counter_double{key1='value1',key2='value2'} 101.17 (\\d+)\n" + + "# TYPE counter_double_total counter\n" + + "counter_double_total{key1='value1',key2='value2'} 101.17 (\\d+)\n" + "\n" + "# EOF\n" + "$").Replace('\'', '"')); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 33180e8c14b..5ef88ad24a1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -134,7 +134,7 @@ private async Task RunPrometheusExporterHttpServerIntegrationTest(bool skipMetri Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); Assert.Matches( - "^# TYPE counter_double counter\ncounter_double{key1='value1',key2='value2'} 101.17 \\d+\n\n# EOF\n$".Replace('\'', '"'), + "^# TYPE counter_double_total counter\ncounter_double_total{key1='value1',key2='value2'} 101.17 \\d+\n\n# EOF\n$".Replace('\'', '"'), await response.Content.ReadAsStringAsync().ConfigureAwait(false)); } else diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs new file mode 100644 index 00000000000..a699d9dfea7 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -0,0 +1,220 @@ +// +// Copyright The OpenTelemetry 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 +// limitations under the License. +// + +using Xunit; + +namespace OpenTelemetry.Exporter.Prometheus.Tests; + +public sealed class PrometheusMetricTests +{ + [Fact] + public void SanitizeMetricName_Valid() + { + AssertSanitizeMetricName("active_directory_ds_replication_network_io", "active_directory_ds_replication_network_io"); + } + + [Fact] + public void SanitizeMetricName_RemoveConsecutiveUnderscores() + { + AssertSanitizeMetricName("cpu_sp__d_hertz", "cpu_sp_d_hertz"); + } + + [Fact] + public void SanitizeMetricName_SupportLeadingAndTrailingUnderscores() + { + AssertSanitizeMetricName("_cpu_speed_hertz_", "_cpu_speed_hertz_"); + } + + [Fact] + public void SanitizeMetricName_RemoveUnsupportedChracters() + { + AssertSanitizeMetricName("metric_unit_$1000", "metric_unit_1000"); + } + + [Fact] + public void SanitizeMetricName_RemoveWhitespace() + { + AssertSanitizeMetricName("unit include", "unit_include"); + } + + [Fact] + public void SanitizeMetricName_RemoveMultipleUnsupportedChracters() + { + AssertSanitizeMetricName("sample_me%%$$$_count_ !!@unit include", "sample_me_count_unit_include"); + } + + [Fact] + public void SanitizeMetricName_RemoveStartingNumber() + { + AssertSanitizeMetricName("1_some_metric_name", "_some_metric_name"); + } + + [Fact] + public void SanitizeMetricName_SupportColon() + { + AssertSanitizeMetricName("sample_metric_name__:_per_meter", "sample_metric_name_:_per_meter"); + } + + [Fact] + public void Unit_Annotation_None() + { + Assert.Equal("Test", PrometheusMetric.RemoveAnnotations("Test")); + } + + [Fact] + public void Unit_Annotation_RemoveLeading() + { + Assert.Equal("%", PrometheusMetric.RemoveAnnotations("%{percentage}")); + } + + [Fact] + public void Unit_Annotation_RemoveTrailing() + { + Assert.Equal("%", PrometheusMetric.RemoveAnnotations("{percentage}%")); + } + + [Fact] + public void Unit_Annotation_RemoveLeadingAndTrailing() + { + Assert.Equal("%", PrometheusMetric.RemoveAnnotations("{percentage}%{percentage}")); + } + + [Fact] + public void Unit_Annotation_RemoveMiddle() + { + Assert.Equal("startend", PrometheusMetric.RemoveAnnotations("start{percentage}end")); + } + + [Fact] + public void Unit_Annotation_RemoveEverything() + { + Assert.Equal(string.Empty, PrometheusMetric.RemoveAnnotations("{percentage}")); + } + + [Fact] + public void Unit_Annotation_Multiple_RemoveEverything() + { + Assert.Equal(string.Empty, PrometheusMetric.RemoveAnnotations("{one}{two}")); + } + + [Fact] + public void Unit_Annotation_NoClose() + { + Assert.Equal("{one", PrometheusMetric.RemoveAnnotations("{one")); + } + + [Fact] + public void Unit_AnnotationMismatch_NoClose() + { + Assert.Equal("}", PrometheusMetric.RemoveAnnotations("{{one}}")); + } + + [Fact] + public void Unit_AnnotationMismatch_Close() + { + Assert.Equal(string.Empty, PrometheusMetric.RemoveAnnotations("{{one}")); + } + + [Fact] + public void Name_SpecialCaseGuage_AppendRatio() + { + AssertName("sample", "1", PrometheusType.Gauge, "sample_ratio"); + } + + [Fact] + public void Name_GuageWithUnit_NoAppendRatio() + { + AssertName("sample", "unit", PrometheusType.Gauge, "sample_unit"); + } + + [Fact] + public void Name_SpecialCaseCounter_AppendTotal() + { + AssertName("sample", "unit", PrometheusType.Counter, "sample_unit_total"); + } + + [Fact] + public void Name_SpecialCaseCounterWithoutUnit_DropUnitAppendTotal() + { + AssertName("sample", "1", PrometheusType.Counter, "sample_total"); + } + + [Fact] + public void Name_SpecialCaseCounterWithNumber_AppendTotal() + { + AssertName("sample", "2", PrometheusType.Counter, "sample_2_total"); + } + + [Fact] + public void Name_UnsupportedMetricNameChars_Drop() + { + AssertName("s%%ple", "%/m", PrometheusType.Summary, "s_ple_percent_per_minute"); + } + + [Fact] + public void Name_UnitOtherThanOne_Normal() + { + AssertName("metric_name", "2", PrometheusType.Summary, "metric_name_2"); + } + + [Fact] + public void Name_UnitAlreadyPresentInName_NotAppended() + { + AssertName("metric_name_total", "total", PrometheusType.Counter, "metric_name_total"); + } + + [Fact] + public void Name_UnitAlreadyPresentInName_TotalNonCounterType_NotAppended() + { + AssertName("metric_name_total", "total", PrometheusType.Summary, "metric_name_total"); + } + + [Fact] + public void Name_UnitAlreadyPresentInName_CustomGauge_NotAppended() + { + AssertName("metric_hertz", "hertz", PrometheusType.Gauge, "metric_hertz"); + } + + [Fact] + public void Name_UnitAlreadyPresentInName_CustomCounter_NotAppended() + { + AssertName("metric_hertz_total", "hertz_total", PrometheusType.Counter, "metric_hertz_total"); + } + + [Fact] + public void Name_UnitAlreadyPresentInName_OrderMatters_Appended() + { + AssertName("metric_total_hertz", "hertz_total", PrometheusType.Counter, "metric_total_hertz_hertz_total"); + } + + [Fact] + public void Name_StartWithNumber_UnderscoreStart() + { + AssertName("2_metric_name", "By", PrometheusType.Summary, "_metric_name_bytes"); + } + + private static void AssertName(string name, string unit, PrometheusType type, string expected) + { + var prometheusMetric = new PrometheusMetric(name, unit, type); + Assert.Equal(expected, prometheusMetric.Name); + } + + private static void AssertSanitizeMetricName(string name, string expected) + { + var sanatizedName = PrometheusMetric.SanitizeMetricName(name); + Assert.Equal(expected, sanatizedName); + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index e2c201caac2..069a2e0c810 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -40,7 +40,7 @@ public void GaugeZeroDimension() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -65,7 +65,7 @@ public void GaugeZeroDimensionWithDescription() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -91,7 +91,7 @@ public void GaugeZeroDimensionWithUnit() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge_seconds gauge\n" @@ -117,7 +117,7 @@ public void GaugeZeroDimensionWithDescriptionAndUnit() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge_seconds gauge\n" @@ -146,7 +146,7 @@ public void GaugeOneDimension() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -176,7 +176,7 @@ public void GaugeDoubleSubnormal() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -205,11 +205,11 @@ public void SumDoubleInfinities() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" - + "# TYPE test_counter counter\n" - + "test_counter \\+Inf \\d+\n" + + "# TYPE test_counter_total counter\n" + + "test_counter_total \\+Inf \\d+\n" + "$").Replace('\'', '"'), Encoding.UTF8.GetString(buffer, 0, cursor)); } @@ -232,7 +232,7 @@ public void SumNonMonotonicDouble() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_updown_counter gauge\n" @@ -259,7 +259,7 @@ public void HistogramZeroDimension() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -303,7 +303,7 @@ public void HistogramOneDimension() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -347,7 +347,7 @@ public void HistogramTwoDimensions() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -392,7 +392,7 @@ public void HistogramInfinities() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -437,7 +437,7 @@ public void HistogramNaN() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0]); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -482,9 +482,11 @@ public void ExponentialHistogramIsIgnoredForNow() provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metrics[0]); - Assert.Matches( - "^$", - Encoding.UTF8.GetString(buffer, 0, cursor)); + Assert.False(PrometheusSerializer.CanWriteMetric(metrics[0])); + } + + private static int WriteMetric(byte[] buffer, int cursor, Metric metric) + { + return PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric)); } } From 5784b45c4e310fc54d84278891bb22d6b93a1246 Mon Sep 17 00:00:00 2001 From: Yun-Ting Lin Date: Wed, 16 Aug 2023 11:48:31 -0700 Subject: [PATCH 2/2] [AOT] suppressed trimming warnings in HTTP Instrumentation package (#4770) Co-authored-by: Eric Erhardt Co-authored-by: Reiley Yang --- build/test-aot-compatibility.ps1 | 2 +- .../HttpHandlerDiagnosticListener.cs | 80 ++++++++++++++++--- .../HttpHandlerMetricsDiagnosticListener.cs | 29 +++++-- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/build/test-aot-compatibility.ps1 b/build/test-aot-compatibility.ps1 index cde8dfc9839..c1c92a0b3b8 100644 --- a/build/test-aot-compatibility.ps1 +++ b/build/test-aot-compatibility.ps1 @@ -29,7 +29,7 @@ if ($LastExitCode -ne 0) popd Write-Host "Actual warning count is:", $actualWarningCount -$expectedWarningCount = 26 +$expectedWarningCount = 19 $testPassed = 0 if ($actualWarningCount -ne $expectedWarningCount) diff --git a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs index 50a1242e421..40bf9a3fab6 100644 --- a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerDiagnosticListener.cs @@ -15,6 +15,9 @@ // using System.Diagnostics; +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif #if NETFRAMEWORK using System.Net.Http; #endif @@ -40,10 +43,10 @@ internal sealed class HttpHandlerDiagnosticListener : ListenerHandler private const string OnStopEvent = "System.Net.Http.HttpRequestOut.Stop"; private const string OnUnhandledExceptionEvent = "System.Net.Http.Exception"; - private readonly PropertyFetcher startRequestFetcher = new("Request"); - private readonly PropertyFetcher stopResponseFetcher = new("Response"); - private readonly PropertyFetcher stopExceptionFetcher = new("Exception"); - private readonly PropertyFetcher stopRequestStatusFetcher = new("RequestTaskStatus"); + private static readonly PropertyFetcher StartRequestFetcher = new("Request"); + private static readonly PropertyFetcher StopResponseFetcher = new("Response"); + private static readonly PropertyFetcher StopExceptionFetcher = new("Exception"); + private static readonly PropertyFetcher StopRequestStatusFetcher = new("RequestTaskStatus"); private readonly HttpClientInstrumentationOptions options; private readonly bool emitOldAttributes; private readonly bool emitNewAttributes; @@ -112,7 +115,7 @@ public void OnStartActivity(Activity activity, object payload) return; } - if (!this.startRequestFetcher.TryFetch(payload, out HttpRequestMessage request) || request == null) + if (!TryFetchRequest(payload, out HttpRequestMessage request)) { HttpInstrumentationEventSource.Log.NullPayload(nameof(HttpHandlerDiagnosticListener), nameof(this.OnStartActivity)); return; @@ -211,15 +214,28 @@ public void OnStartActivity(Activity activity, object payload) HttpInstrumentationEventSource.Log.EnrichmentException(ex); } } + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static bool TryFetchRequest(object payload, out HttpRequestMessage request) + { + if (!StartRequestFetcher.TryFetch(payload, out request) || request == null) + { + return false; + } + + return true; + } } public void OnStopActivity(Activity activity, object payload) { if (activity.IsAllDataRequested) { - // https://github.com/dotnet/runtime/blob/master/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs - // requestTaskStatus is not null - _ = this.stopRequestStatusFetcher.TryFetch(payload, out var requestTaskStatus); + var requestTaskStatus = GetRequestStatus(payload); ActivityStatusCode currentStatusCode = activity.Status; if (requestTaskStatus != TaskStatus.RanToCompletion) @@ -241,7 +257,7 @@ public void OnStopActivity(Activity activity, object payload) } } - if (this.stopResponseFetcher.TryFetch(payload, out HttpResponseMessage response) && response != null) + if (TryFetchResponse(payload, out HttpResponseMessage response)) { if (this.emitOldAttributes) { @@ -267,6 +283,35 @@ public void OnStopActivity(Activity activity, object payload) HttpInstrumentationEventSource.Log.EnrichmentException(ex); } } + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static TaskStatus GetRequestStatus(object payload) + { + // requestTaskStatus (type is TaskStatus) is a non-nullable enum so we don't need to have a null check here. + // See: https://github.com/dotnet/runtime/blob/79c021d65c280020246d1035b0e87ae36f2d36a9/src/libraries/System.Net.Http/src/HttpDiagnosticsGuide.md?plain=1#L69 + _ = StopRequestStatusFetcher.TryFetch(payload, out var requestTaskStatus); + + return requestTaskStatus; + } + } + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static bool TryFetchResponse(object payload, out HttpResponseMessage response) + { + if (StopResponseFetcher.TryFetch(payload, out response) && response != null) + { + return true; + } + + return false; } } @@ -274,7 +319,7 @@ public void OnException(Activity activity, object payload) { if (activity.IsAllDataRequested) { - if (!this.stopExceptionFetcher.TryFetch(payload, out Exception exc) || exc == null) + if (!TryFetchException(payload, out Exception exc)) { HttpInstrumentationEventSource.Log.NullPayload(nameof(HttpHandlerDiagnosticListener), nameof(this.OnException)); return; @@ -299,5 +344,20 @@ public void OnException(Activity activity, object payload) HttpInstrumentationEventSource.Log.EnrichmentException(ex); } } + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static bool TryFetchException(object payload, out Exception exc) + { + if (!StopExceptionFetcher.TryFetch(payload, out exc) || exc == null) + { + return false; + } + + return true; + } } } diff --git a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs index fc0685cb331..3aac7b32160 100644 --- a/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.Http/Implementation/HttpHandlerMetricsDiagnosticListener.cs @@ -15,6 +15,9 @@ // using System.Diagnostics; +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif using System.Diagnostics.Metrics; #if NETFRAMEWORK using System.Net.Http; @@ -28,8 +31,8 @@ internal sealed class HttpHandlerMetricsDiagnosticListener : ListenerHandler { internal const string OnStopEvent = "System.Net.Http.HttpRequestOut.Stop"; - private readonly PropertyFetcher stopResponseFetcher = new("Response"); - private readonly PropertyFetcher stopRequestFetcher = new("Request"); + private static readonly PropertyFetcher StopRequestFetcher = new("Request"); + private static readonly PropertyFetcher StopResponseFetcher = new("Response"); private readonly Histogram httpClientDuration; private readonly HttpClientMetricInstrumentationOptions options; private readonly bool emitOldAttributes; @@ -56,7 +59,7 @@ public override void OnEventWritten(string name, object payload) } var activity = Activity.Current; - if (this.stopRequestFetcher.TryFetch(payload, out HttpRequestMessage request) && request != null) + if (TryFetchRequest(payload, out HttpRequestMessage request)) { TagList tags = default; @@ -73,7 +76,7 @@ public override void OnEventWritten(string name, object payload) tags.Add(new KeyValuePair(SemanticConventions.AttributeNetPeerPort, request.RequestUri.Port)); } - if (this.stopResponseFetcher.TryFetch(payload, out HttpResponseMessage response) && response != null) + if (TryFetchResponse(payload, out HttpResponseMessage response)) { tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode))); } @@ -91,7 +94,7 @@ public override void OnEventWritten(string name, object payload) tags.Add(new KeyValuePair(SemanticConventions.AttributeServerPort, request.RequestUri.Port)); } - if (this.stopResponseFetcher.TryFetch(payload, out HttpResponseMessage response) && response != null) + if (TryFetchResponse(payload, out HttpResponseMessage response)) { tags.Add(new KeyValuePair(SemanticConventions.AttributeHttpResponseStatusCode, TelemetryHelper.GetBoxedStatusCode(response.StatusCode))); } @@ -103,5 +106,21 @@ public override void OnEventWritten(string name, object payload) this.httpClientDuration.Record(activity.Duration.TotalMilliseconds, tags); } } + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static bool TryFetchRequest(object payload, out HttpRequestMessage request) => + StopRequestFetcher.TryFetch(payload, out request) && request != null; + + // The AOT-annotation DynamicallyAccessedMembers in System.Net.Http library ensures that top-level properties on the payload object are always preserved. + // see https://github.com/dotnet/runtime/blob/f9246538e3d49b90b0e9128d7b1defef57cd6911/src/libraries/System.Net.Http/src/System/Net/Http/DiagnosticsHandler.cs#L325 +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "The event source guarantees that top-level properties are preserved")] +#endif + static bool TryFetchResponse(object payload, out HttpResponseMessage response) => + StopResponseFetcher.TryFetch(payload, out response) && response != null; } }