diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index 71c1dd890df..4e040e1ef6b 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -37,6 +37,10 @@ Notes](../../RELEASENOTES.md). * Do not enable the integration with `IHttpClientFactory` when mTLS is enabled. ([#7305](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7305)) +* Cached pre-serialized metric metadata (`Name` / `Description` / `Unit`) to avoid + re-encoding on every OTLP metric export. + ([#7307](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7307)) + ## 1.15.3 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpMetricSerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpMetricSerializer.cs index 23e01029c8f..19cb4f56672 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpMetricSerializer.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpMetricSerializer.cs @@ -1,7 +1,9 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Buffers; using System.Diagnostics; +using System.Runtime.CompilerServices; using OpenTelemetry.Metrics; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer; @@ -11,6 +13,10 @@ internal static class ProtobufOtlpMetricSerializer private const int ReserveSizeForLength = 4; private const int TraceIdSize = 16; private const int SpanIdSize = 8; + private const int StackallocByteThreshold = 256; + private const int InitialPoolBufferSize = 512; + + private static readonly ConditionalWeakTable CachedMetricMetadata = new(); [ThreadStatic] private static Stack>? metricListPool; @@ -172,17 +178,9 @@ private static int WriteMetric(byte[] buffer, int writePosition, Metric metric) var metricLengthPosition = writePosition; writePosition += ReserveSizeForLength; - writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Name, metric.Name); - - if (metric.Description != null) - { - writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Description, metric.Description); - } - - if (metric.Unit != null) - { - writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Unit, metric.Unit); - } + var cachedMetadata = CachedMetricMetadata.GetOrAdd(metric, SerializeMetricMetadataToBytes); + Buffer.BlockCopy(cachedMetadata, 0, buffer, writePosition, cachedMetadata.Length); + writePosition += cachedMetadata.Length; var aggregationValue = metric.Temporality == AggregationTemporality.Cumulative ? ProtobufOtlpMetricFieldNumberConstants.Aggregation_Temporality_Cumulative @@ -592,4 +590,62 @@ static int WritePackedLength(byte[] buffer, int writePosition, int length, int f ProtobufWireType.LEN); } } + + private static byte[] SerializeMetricMetadataToBytes(Metric metric) + { + Span stackBuffer = stackalloc byte[StackallocByteThreshold]; + try + { + var length = WriteMetricMetadataCore(stackBuffer, 0, metric); + return stackBuffer.Slice(0, length).ToArray(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + return SerializeMetricMetadataToBytesFromPool(metric); + } + } + + private static byte[] SerializeMetricMetadataToBytesFromPool(Metric metric) + { + var pool = ArrayPool.Shared; + + var buffer = pool.Rent(InitialPoolBufferSize); + try + { + while (true) + { + try + { + var length = WriteMetricMetadataCore(buffer, 0, metric); + return buffer.AsSpan(0, length).ToArray(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + pool.Return(buffer); + buffer = pool.Rent(buffer.Length * 2); + } + } + } + finally + { + pool.Return(buffer); + } + } + + private static int WriteMetricMetadataCore(Span buffer, int writePosition, Metric metric) + { + writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Name, metric.Name); + + if (metric.Description != null) + { + writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Description, metric.Description); + } + + if (metric.Unit != null) + { + writePosition = ProtobufSerializer.WriteStringWithTag(buffer, writePosition, ProtobufOtlpMetricFieldNumberConstants.Metric_Unit, metric.Unit); + } + + return writePosition; + } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpResourceSerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpResourceSerializer.cs index 05480fbf233..0454804ec5f 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpResourceSerializer.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpResourceSerializer.cs @@ -24,12 +24,7 @@ internal static int WriteResource(byte[] buffer, int writePosition, Resource? re return writePosition + EmptyResourceBytes.Length; } -#if NET10_0_OR_GREATER var cached = CachedResourceBytes.GetOrAdd(resource, SerializeResourceToBytes); -#else - var cached = CachedResourceBytes.GetValue(resource, SerializeResourceToBytes); -#endif - Buffer.BlockCopy(cached, 0, buffer, writePosition, cached.Length); return writePosition + cached.Length; } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufSerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufSerializer.cs index 2df8e0edfb2..b07c2054887 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufSerializer.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufSerializer.cs @@ -4,9 +4,6 @@ using System.Buffers.Binary; using System.Diagnostics; using System.Runtime.CompilerServices; -#if NETFRAMEWORK || NETSTANDARD2_0 -using System.Runtime.InteropServices; -#endif using System.Text; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer; @@ -257,19 +254,7 @@ internal static int WriteStringWithTag(byte[] buffer, int writePosition, int fie [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static int GetNumberOfUtf8CharsInString(ReadOnlySpan value) { -#if NETFRAMEWORK || NETSTANDARD2_0 - int numberOfUtf8CharsInString; - unsafe - { - fixed (char* strPtr = &GetNonNullPinnableReference(value)) - { - numberOfUtf8CharsInString = Utf8Encoding.GetByteCount(strPtr, value.Length); - } - } -#else - var numberOfUtf8CharsInString = Utf8Encoding.GetByteCount(value); -#endif - return numberOfUtf8CharsInString; + return Utf8Encoding.GetByteCount(value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -285,31 +270,55 @@ internal static int WriteStringWithTag(byte[] buffer, int writePosition, int fie writePosition = WriteTag(buffer, writePosition, fieldNumber, ProtobufWireType.LEN); writePosition = WriteLength(buffer, writePosition, numberOfUtf8CharsInString); -#if NETFRAMEWORK || NETSTANDARD2_0 - if (buffer.Length - writePosition < numberOfUtf8CharsInString) - { - // Note: Validate there is enough space in the buffer to hold the - // string otherwise throw to trigger a resize of the buffer. -#pragma warning disable CA2201 // Do not raise reserved exception types - throw new IndexOutOfRangeException(); -#pragma warning restore CA2201 // Do not raise reserved exception types - } + var bytesWritten = Utf8Encoding.GetBytes(value, buffer.AsSpan(writePosition)); + Debug.Assert(bytesWritten == numberOfUtf8CharsInString, "bytesWritten did not match numberOfUtf8CharsInString"); - unsafe + writePosition += numberOfUtf8CharsInString; + return writePosition; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteVarInt32(Span buffer, int writePosition, uint value) + { + while (value >= UInt128) { - fixed (char* strPtr = &GetNonNullPinnableReference(value)) - { - fixed (byte* bufferPtr = buffer) - { - var bytesWritten = Utf8Encoding.GetBytes(strPtr, value.Length, bufferPtr + writePosition, numberOfUtf8CharsInString); - Debug.Assert(bytesWritten == numberOfUtf8CharsInString, "bytesWritten did not match numberOfUtf8CharsInString"); - } - } + buffer[writePosition++] = (byte)(MaskBitHigh | (value & MaskBitsLow)); + value >>= 7; } -#else - var bytesWritten = Utf8Encoding.GetBytes(value, buffer.AsSpan(writePosition)); + + buffer[writePosition++] = (byte)value; + return writePosition; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteTag(Span buffer, int writePosition, int fieldNumber, ProtobufWireType type) => WriteVarInt32(buffer, writePosition, GetTagValue(fieldNumber, type)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteLength(Span buffer, int writePosition, int length) => WriteVarInt32(buffer, writePosition, (uint)length); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteStringWithTag(Span buffer, int writePosition, int fieldNumber, string value) + { + Debug.Assert(value != null, "value was null"); + + return WriteStringWithTag(buffer, writePosition, fieldNumber, value.AsSpan()); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteStringWithTag(Span buffer, int writePosition, int fieldNumber, ReadOnlySpan value) + { + var numberOfUtf8CharsInString = GetNumberOfUtf8CharsInString(value); + return WriteStringWithTag(buffer, writePosition, fieldNumber, numberOfUtf8CharsInString, value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteStringWithTag(Span buffer, int writePosition, int fieldNumber, int numberOfUtf8CharsInString, ReadOnlySpan value) + { + writePosition = WriteTag(buffer, writePosition, fieldNumber, ProtobufWireType.LEN); + writePosition = WriteLength(buffer, writePosition, numberOfUtf8CharsInString); + + var bytesWritten = Utf8Encoding.GetBytes(value, buffer.Slice(writePosition)); Debug.Assert(bytesWritten == numberOfUtf8CharsInString, "bytesWritten did not match numberOfUtf8CharsInString"); -#endif writePosition += numberOfUtf8CharsInString; return writePosition; @@ -335,10 +344,4 @@ internal static bool IncreaseBufferSize(ref byte[] buffer, OtlpSignalType otlpSi return false; } } - -#if NETFRAMEWORK || NETSTANDARD2_0 - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static unsafe ref T GetNonNullPinnableReference(ReadOnlySpan span) - => ref (span.Length != 0) ? ref Unsafe.AsRef(in MemoryMarshal.GetReference(span)) : ref Unsafe.AsRef((void*)1); -#endif } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj index 37170f94da0..9653a78eae5 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj @@ -35,6 +35,10 @@ + + + + diff --git a/src/Shared/ConditionalWeakTableExtensions.cs b/src/Shared/ConditionalWeakTableExtensions.cs new file mode 100644 index 00000000000..abb781d0e26 --- /dev/null +++ b/src/Shared/ConditionalWeakTableExtensions.cs @@ -0,0 +1,26 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET10_0_OR_GREATER + +using System.Diagnostics.CodeAnalysis; + +namespace System.Runtime.CompilerServices; + +/// +/// Polyfills the GetOrAdd APIs added to in .NET 10. +/// See https://github.com/dotnet/runtime/pull/111204. +/// +internal static class ConditionalWeakTableExtensions +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TValue GetOrAdd( + this ConditionalWeakTable table, + TKey key, + ConditionalWeakTable.CreateValueCallback valueFactory) + where TKey : class + where TValue : class + => table.GetValue(key, valueFactory); +} + +#endif diff --git a/src/Shared/DynamicallyAccessedMemberTypes.cs b/src/Shared/DynamicallyAccessedMemberTypes.cs new file mode 100644 index 00000000000..93df33f7ddb --- /dev/null +++ b/src/Shared/DynamicallyAccessedMemberTypes.cs @@ -0,0 +1,102 @@ +// +#pragma warning disable +#nullable enable annotations + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET + +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Specifies the types of members that are dynamically accessed. +/// +/// This enumeration has a attribute that allows a +/// bitwise combination of its member values. +/// +[global::System.Flags] +internal enum DynamicallyAccessedMemberTypes +{ + /// + /// Specifies no members. + /// + None = 0, + + /// + /// Specifies the default, parameterless public constructor. + /// + PublicParameterlessConstructor = 0x0001, + + /// + /// Specifies all public constructors. + /// + PublicConstructors = 0x0002 | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, + + /// + /// Specifies all non-public constructors. + /// + NonPublicConstructors = 0x0004, + + /// + /// Specifies all public methods. + /// + PublicMethods = 0x0008, + + /// + /// Specifies all non-public methods. + /// + NonPublicMethods = 0x0010, + + /// + /// Specifies all public fields. + /// + PublicFields = 0x0020, + + /// + /// Specifies all non-public fields. + /// + NonPublicFields = 0x0040, + + /// + /// Specifies all public nested types. + /// + PublicNestedTypes = 0x0080, + + /// + /// Specifies all non-public nested types. + /// + NonPublicNestedTypes = 0x0100, + + /// + /// Specifies all public properties. + /// + PublicProperties = 0x0200, + + /// + /// Specifies all non-public properties. + /// + NonPublicProperties = 0x0400, + + /// + /// Specifies all public events. + /// + PublicEvents = 0x0800, + + /// + /// Specifies all non-public events. + /// + NonPublicEvents = 0x1000, + + /// + /// Specifies all interfaces implemented by the type. + /// + Interfaces = 0x2000, + + /// + /// Specifies all members. + /// + All = ~global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.None +} + +#endif diff --git a/src/Shared/DynamicallyAccessedMembersAttribute.cs b/src/Shared/DynamicallyAccessedMembersAttribute.cs new file mode 100644 index 00000000000..295f4e39192 --- /dev/null +++ b/src/Shared/DynamicallyAccessedMembersAttribute.cs @@ -0,0 +1,65 @@ +// +#pragma warning disable +#nullable enable annotations + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET + +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Indicates that certain members on a specified are accessed dynamically, +/// for example through . +/// +/// +/// This allows tools to understand which members are being accessed during the execution +/// of a program. +/// +/// This attribute is valid on members whose type is or . +/// +/// When this attribute is applied to a location of type , the assumption is +/// that the string represents a fully qualified type name. +/// +/// When this attribute is applied to a class, interface, or struct, the members specified +/// can be accessed dynamically on instances returned from calling +/// on instances of that class, interface, or struct. +/// +/// If the attribute is applied to a method it's treated as a special case and it implies +/// the attribute should be applied to the "this" parameter of the method. As such the attribute +/// should only be used on instance methods of types assignable to System.Type (or string, but no methods +/// will use it there). +/// +[global::System.AttributeUsage( + global::System.AttributeTargets.Field | + global::System.AttributeTargets.ReturnValue | + global::System.AttributeTargets.GenericParameter | + global::System.AttributeTargets.Parameter | + global::System.AttributeTargets.Property | + global::System.AttributeTargets.Method | + global::System.AttributeTargets.Class | + global::System.AttributeTargets.Interface | + global::System.AttributeTargets.Struct, + Inherited = false)] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +internal sealed class DynamicallyAccessedMembersAttribute : global::System.Attribute +{ + /// + /// Initializes a new instance of the class + /// with the specified member types. + /// + /// The types of members dynamically accessed. + public DynamicallyAccessedMembersAttribute(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes memberTypes) + { + MemberTypes = memberTypes; + } + + /// + /// Gets the which specifies the type + /// of members dynamically accessed. + /// + public global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes MemberTypes { get; } +} + +#endif diff --git a/src/Shared/EncodingExtensions.cs b/src/Shared/EncodingExtensions.cs new file mode 100644 index 00000000000..2b8e2174873 --- /dev/null +++ b/src/Shared/EncodingExtensions.cs @@ -0,0 +1,49 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NETFRAMEWORK || NETSTANDARD2_0 + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace System.Text; + +/// +/// Polyfills span-based APIs missing on legacy targets. Mirrors +/// https://github.com/dotnet/runtime/blob/74b0096b51f360e7650ed0b347fb18cabe75498a/src/libraries/Common/src/Polyfills/EncodingPolyfills.cs. +/// +internal static class EncodingExtensions +{ +#pragma warning disable SA1101 // Prefix local calls with this - extension receiver is the parameter, not 'this'. +#pragma warning disable SA1519 // Braces should not be omitted - chained 'fixed' statements are idiomatic. + extension(Encoding encoding) + { + public unsafe int GetByteCount(ReadOnlySpan chars) + { + fixed (char* charsPtr = &GetNonNullPinnableReference(chars)) + { + return encoding.GetByteCount(charsPtr, chars.Length); + } + } + + public unsafe int GetBytes(ReadOnlySpan chars, Span bytes) + { + fixed (char* charsPtr = &GetNonNullPinnableReference(chars)) + fixed (byte* bytesPtr = &GetNonNullPinnableReference(bytes)) + { + return encoding.GetBytes(charsPtr, chars.Length, bytesPtr, bytes.Length); + } + } + } +#pragma warning restore SA1519 +#pragma warning restore SA1101 + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe ref readonly T GetNonNullPinnableReference(ReadOnlySpan buffer) + { + // Based on the internal implementation from MemoryMarshal. + return ref buffer.Length != 0 ? ref MemoryMarshal.GetReference(buffer) : ref Unsafe.AsRef((void*)1); + } +} + +#endif diff --git a/test/Benchmarks/Exporter/ProtobufOtlpMetricSerializerBenchmarks.cs b/test/Benchmarks/Exporter/ProtobufOtlpMetricSerializerBenchmarks.cs new file mode 100644 index 00000000000..86ee25c9b1a --- /dev/null +++ b/test/Benchmarks/Exporter/ProtobufOtlpMetricSerializerBenchmarks.cs @@ -0,0 +1,93 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +extern alias OpenTelemetryProtocol; + +using System.Diagnostics.Metrics; +using BenchmarkDotNet.Attributes; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetryProtocol::OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer; + +namespace Benchmarks.Exporter; + +#pragma warning disable CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +public class ProtobufOtlpMetricSerializerBenchmarks +#pragma warning restore CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +{ + private readonly byte[] buffer = new byte[256 * 1024]; + private Meter? meter; + private MeterProvider? meterProvider; + private Metric[]? capturedMetrics; + private Batch batch; + + [Params(1, 4, 16, 64, 256)] + public int MetricCount { get; set; } + + [GlobalSetup] + public void Setup() + { + var meterName = "benchmark.metric-serializer." + Guid.NewGuid().ToString("N"); + this.meter = new Meter(meterName); + + var collected = new List(); +#pragma warning disable CA2000 // Ownership transferred to MeterProvider via AddReader. + var captureExporter = new CaptureExporter(collected); + var reader = new BaseExportingMetricReader(captureExporter); +#pragma warning restore CA2000 + + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meterName) + .AddReader(reader) + .Build(); + + for (var i = 0; i < this.MetricCount; i++) + { + var counter = this.meter.CreateCounter( + name: "benchmark.requests." + i, + unit: "requests", + description: "Number of requests processed by component " + i); + counter.Add(1); + } + + this.meterProvider.ForceFlush(); + + this.capturedMetrics = collected.ToArray(); + this.batch = new Batch(this.capturedMetrics, this.capturedMetrics.Length); + } + + [Benchmark] + public int WriteMetricsData() + { + var buf = this.buffer; + return ProtobufOtlpMetricSerializer.WriteMetricsData(ref buf, 0, Resource.Empty, in this.batch); + } + + [GlobalCleanup] + public void Cleanup() + { + this.meterProvider?.Dispose(); + this.meter?.Dispose(); + } + + private sealed class CaptureExporter : BaseExporter + { + private readonly List collected; + + public CaptureExporter(List collected) + { + this.collected = collected; + } + + public override ExportResult Export(in Batch batch) + { + foreach (var metric in batch) + { + this.collected.Add(metric); + } + + return ExportResult.Success; + } + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufOtlpMetricSerializerTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufOtlpMetricSerializerTests.cs index a98fca4f4c4..4e83ae20a4c 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufOtlpMetricSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufOtlpMetricSerializerTests.cs @@ -3,11 +3,13 @@ using System.Diagnostics.Metrics; using System.Reflection; +using System.Runtime.CompilerServices; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Serializer; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Tests; +using OtlpCollector = OpenTelemetry.Proto.Collector.Metrics.V1; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests.Implementation.Serializer; @@ -15,6 +17,42 @@ public static class ProtobufOtlpMetricSerializerTests { private const string HistogramName = "histogram"; + [Theory] + [InlineData(700)] + [InlineData(2000)] + public static void WriteMetricsData_Serializes_Metrics_With_OversizedMetadata(int descriptionLength) + { + var description = new string('a', descriptionLength); + var metrics = GenerateMetricWithDescription(description); + + var buffer = new byte[16 * 1024]; + var writePosition = ProtobufOtlpMetricSerializer.WriteMetricsData( + ref buffer, + 0, + Resource.Empty, + metrics); + + Assert.True(writePosition > 0); + Assert.True(writePosition <= buffer.Length); + + using var stream = new MemoryStream(buffer, 0, writePosition); + var request = OtlpCollector.ExportMetricsServiceRequest.Parser.ParseFrom(stream); + var parsedMetric = request.ResourceMetrics[0].ScopeMetrics[0].Metrics[0]; + Assert.Equal(description, parsedMetric.Description); + } + + [Fact] + public static void WriteMetricsDataDoesNotKeepMetricAlive() + { + var reference = CreateSerializedMetricWeakReference(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.False(reference.TryGetTarget(out _), "Metric should not be kept alive after serialization."); + } + [Fact] public static async Task WriteMetricsData_Serializes_Metrics_Correctly() { @@ -172,4 +210,73 @@ private static Batch GenerateMetrics(Action? confi return metrics; } + + private static Batch GenerateMetricWithDescription(string description) + { + Batch metrics = default; + + using (var exported = new ManualResetEvent(false)) + { + using var exporter = new DelegatingExporter() + { + OnExportFunc = (batch) => + { + metrics = batch; + exported.Set(); + return ExportResult.Success; + }, + }; + + var meterName = "otlp.protobuf.large-metadata"; + + var experimentalOptions = new ExperimentalOptions(); + var exporterOptions = new OtlpExporterOptions() + { + Endpoint = new($"http://localhost:4318/v1/"), + Protocol = OtlpExportProtocol.HttpProtobuf, + }; + + var metricReaderOptions = new MetricReaderOptions(); + metricReaderOptions.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = Timeout.Infinite; + + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meterName) + .AddReader( + (serviceProvider) => OtlpMetricExporterExtensions.BuildOtlpExporterMetricReader( + serviceProvider, + exporterOptions, + metricReaderOptions, + experimentalOptions, + configureExporterInstance: (_) => exporter)) + .Build(); + + using var meter = new Meter(meterName); + + var counter = meter.CreateCounter(name: "test.counter", unit: "1", description: description); + counter.Add(1); + + Assert.True(meterProvider.ForceFlush()); + Assert.NotEqual(0, metrics.Count); + } + + return metrics; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateSerializedMetricWeakReference() + { + var metrics = GenerateMetrics(); + + Metric capturedMetric = null!; + foreach (var metric in metrics) + { + capturedMetric = metric; + break; + } + + var buffer = new byte[16 * 1024]; + _ = ProtobufOtlpMetricSerializer.WriteMetricsData(ref buffer, 0, Resource.Empty, metrics); + + return new WeakReference(capturedMetric); + } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufSerializerTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufSerializerTests.cs index 99e409b9c3e..7be3fabc48b 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/Serializer/ProtobufSerializerTests.cs @@ -355,4 +355,25 @@ public void WriteStringWithTag_SurrogatePairs_WritesCorrectly() Array.Copy(buffer, 2, actualContent, 0, 4); Assert.True(expectedContent.SequenceEqual(actualContent)); } + + [Theory] + [InlineData("")] + [InlineData("Hello")] + [InlineData("Hello\u4E16\u754C")] + [InlineData("\u3053\u3093\u306B\u3061\u306F")] + [InlineData("\uD83D\uDCD6")] + [InlineData("Hello\n\t\"World\"")] + [InlineData("Hello\0World")] + public void WriteStringWithTag_Span_ProducesSameBytesAsArray(string value) + { + var arrayBuffer = new byte[1024]; + var arrayPosition = ProtobufSerializer.WriteStringWithTag(arrayBuffer, 0, 1, value); + + var spanBacking = new byte[1024]; + Span spanBuffer = spanBacking; + var spanPosition = ProtobufSerializer.WriteStringWithTag(spanBuffer, 0, 1, value); + + Assert.Equal(arrayPosition, spanPosition); + Assert.True(arrayBuffer.AsSpan(0, arrayPosition).SequenceEqual(spanBuffer.Slice(0, spanPosition))); + } }