diff --git a/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs b/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs index be150770fe6..e4ed725ec8d 100644 --- a/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs +++ b/src/OpenTelemetry.Api/Context/Propagation/BaggagePropagator.cs @@ -20,8 +20,9 @@ public class BaggagePropagator : TextMapPropagator private const int MaxBaggageLength = 8192; private const int MaxBaggageItems = 180; - private static readonly char[] EqualSignSeparator = ['=']; +#if !NET private static readonly char[] CommaSignSeparator = [',']; +#endif /// public override ISet Fields => new HashSet { BaggageHeaderName }; @@ -50,9 +51,9 @@ public override PropagationContext Extract(PropagationContext context, T carr try { var baggageCollection = getter(carrier, BaggageHeaderName); - if (baggageCollection?.Any() ?? false) + if (baggageCollection is not null) { - if (TryExtractBaggage([.. baggageCollection], out var baggageItems)) + if (TryExtractBaggage(baggageCollection, out var baggageItems)) { Baggage baggage = #if NET @@ -113,7 +114,7 @@ public override void Inject(PropagationContext context, T carrier, Action baggageCollection, #if NET [NotNullWhen(true)] #endif @@ -135,9 +136,25 @@ internal static bool TryExtractBaggage( continue; } - foreach (var pair in item.Split(CommaSignSeparator)) +#if NET + var span = item.AsSpan(); + while (!span.IsEmpty) { - baggageLength += pair.Length + 1; // pair and comma + ReadOnlySpan pairSpan; + + var index = span.IndexOf(','); + if (index < 0) + { + pairSpan = span; + span = default; + } + else + { + pairSpan = span[..index]; + span = span[(index + 1)..]; + } + + baggageLength += pairSpan.Length + 1; if (baggageLength >= MaxBaggageLength || baggageDictionary?.Count >= MaxBaggageItems) { @@ -145,23 +162,42 @@ internal static bool TryExtractBaggage( break; } -#if NET - if (pair.IndexOf('=', StringComparison.Ordinal) < 0) -#else - if (pair.IndexOf('=') < 0) -#endif + index = pairSpan.IndexOf('='); + if (index < 0) { continue; } - var parts = pair.Split(EqualSignSeparator, 2); - if (parts.Length != 2) + var key = WebUtility.UrlDecode(pairSpan[..index].ToString()); + var value = WebUtility.UrlDecode(pairSpan[(index + 1)..].ToString()); + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) { continue; } - var key = WebUtility.UrlDecode(parts[0]); - var value = WebUtility.UrlDecode(parts[1]); + baggageDictionary ??= []; + baggageDictionary[key] = value; + } +#else + foreach (var pair in item.Split(CommaSignSeparator)) + { + baggageLength += pair.Length + 1; + + if (baggageLength >= MaxBaggageLength || baggageDictionary?.Count >= MaxBaggageItems) + { + done = true; + break; + } + + var index = pair.IndexOf('='); + if (index < 0) + { + continue; + } + + var key = WebUtility.UrlDecode(pair.Substring(0, index)); + var value = WebUtility.UrlDecode(pair.Substring(index + 1)); if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value)) { @@ -169,9 +205,9 @@ internal static bool TryExtractBaggage( } baggageDictionary ??= []; - baggageDictionary[key] = value; } +#endif } baggage = baggageDictionary; diff --git a/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs b/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs index 66ddc493807..ead86acde17 100644 --- a/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs +++ b/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs @@ -56,15 +56,11 @@ public override PropagationContext Extract(PropagationContext context, T carr try { - var traceparentCollection = getter(carrier, TraceParent); - - // There must be a single traceparent - if (traceparentCollection == null || traceparentCollection.Count() != 1) + if (!TryGetSingleValue(getter(carrier, TraceParent), out var traceparent)) { return context; } - var traceparent = traceparentCollection.First(); var traceparentParsed = TryExtractTraceparent(traceparent, out var traceId, out var spanId, out var traceoptions); if (!traceparentParsed) @@ -73,10 +69,10 @@ public override PropagationContext Extract(PropagationContext context, T carr } string? tracestate = null; - var tracestateCollection = getter(carrier, TraceState); - if (tracestateCollection?.Any() ?? false) + TryExtractTracestate(getter(carrier, TraceState), out var extractedTracestate, out var hasTraceState); + if (hasTraceState) { - TryExtractTracestate([.. tracestateCollection], out tracestate); + tracestate = extractedTracestate; } return new PropagationContext( @@ -220,94 +216,322 @@ internal static bool TryExtractTraceparent(string traceparent, out ActivityTrace return true; } - internal static bool TryExtractTracestate(string[] tracestateCollection, out string tracestateResult) + internal static bool TryExtractTracestate(string[]? tracestateCollection, out string tracestateResult) + => TryExtractTracestate((IEnumerable?)tracestateCollection, out tracestateResult); + + internal static bool TryExtractTracestate(IEnumerable? tracestateCollection, out string tracestateResult) + => TryExtractTracestate(tracestateCollection, out tracestateResult, out _); + + private static bool TryExtractTracestate(IEnumerable? tracestateCollection, out string tracestateResult, out bool hasTraceState) { tracestateResult = string.Empty; + hasTraceState = false; + + if (tracestateCollection == null) + { + return true; + } - if (tracestateCollection != null) + if (tracestateCollection is IList list) { - var keySet = new HashSet(); - var result = new StringBuilder(); - for (var i = 0; i < tracestateCollection.Length; ++i) + if (list.Count == 0) { - var tracestate = tracestateCollection[i].AsSpan(); - var begin = 0; - while (begin < tracestate.Length) + return true; + } + + hasTraceState = true; + if (list.Count == 1) + { + return TryExtractSingleTracestate(list[0], out tracestateResult); + } + + return TryExtractMultipleTracestate(list, out tracestateResult); + } + + if (tracestateCollection is IReadOnlyList readOnlyList) + { + if (readOnlyList.Count == 0) + { + return true; + } + + hasTraceState = true; + if (readOnlyList.Count == 1) + { + return TryExtractSingleTracestate(readOnlyList[0], out tracestateResult); + } + + return TryExtractMultipleTracestate(readOnlyList, out tracestateResult); + } + + using var enumerator = tracestateCollection.GetEnumerator(); + if (!enumerator.MoveNext()) + { + return true; + } + + hasTraceState = true; + var singleTraceState = enumerator.Current; + if (!enumerator.MoveNext()) + { + return TryExtractSingleTracestate(singleTraceState, out tracestateResult); + } + + return TryExtractMultipleTracestate(EnumerateFrom(singleTraceState, enumerator), out tracestateResult); + } + + private static IEnumerable EnumerateFrom(string first, IEnumerator enumerator) + { + yield return first; + + do + { + yield return enumerator.Current; + } + while (enumerator.MoveNext()); + } + + private static bool TryExtractMultipleTracestate(IEnumerable tracestateCollection, out string tracestateResult) + { + var keySet = new HashSet(); + var result = new StringBuilder(); + + foreach (var tracestateEntry in tracestateCollection) + { + var tracestate = tracestateEntry.AsSpan(); + var begin = 0; + while (begin < tracestate.Length) + { + ReadOnlySpan listMember; + + var length = tracestate.Slice(begin).IndexOf(','); + if (length != -1) { - var length = tracestate.Slice(begin).IndexOf(','); - ReadOnlySpan listMember; - if (length != -1) - { - listMember = tracestate.Slice(begin, length).Trim(); - begin += length + 1; - } - else - { - listMember = tracestate.Slice(begin).Trim(); - begin = tracestate.Length; - } + listMember = tracestate.Slice(begin, length).Trim(); + begin += length + 1; + } + else + { + listMember = tracestate.Slice(begin).Trim(); + begin = tracestate.Length; + } - // https://github.com/w3c/trace-context/blob/master/spec/20-http_request_header_format.md#tracestate-header-field-values - if (listMember.IsEmpty) - { - // Empty and whitespace - only list members are allowed. - // Vendors MUST accept empty tracestate headers but SHOULD avoid sending them. - continue; - } + // https://github.com/w3c/trace-context/blob/master/spec/20-http_request_header_format.md#tracestate-header-field-values + if (listMember.IsEmpty) + { + // Empty and whitespace - only list members are allowed. + // Vendors MUST accept empty tracestate headers but SHOULD avoid sending them. + continue; + } - if (keySet.Count >= 32) - { - // https://github.com/w3c/trace-context/blob/master/spec/20-http_request_header_format.md#list - // test_tracestate_member_count_limit - return false; - } + if (keySet.Count >= 32) + { + // https://github.com/w3c/trace-context/blob/master/spec/20-http_request_header_format.md#list + // test_tracestate_member_count_limit + tracestateResult = string.Empty; + return false; + } - var keyLength = listMember.IndexOf('='); - if (keyLength == listMember.Length || keyLength == -1) - { - // Missing key or value in tracestate - return false; - } + var keyLength = listMember.IndexOf('='); + if (keyLength == listMember.Length || keyLength == -1) + { + // Missing key or value in tracestate + tracestateResult = string.Empty; + return false; + } - var key = listMember.Slice(0, keyLength); - if (!ValidateKey(key)) - { - // test_tracestate_key_illegal_characters in https://github.com/w3c/trace-context/blob/master/test/test.py - // test_tracestate_key_length_limit - // test_tracestate_key_illegal_vendor_format - return false; - } + var key = listMember.Slice(0, keyLength); + if (!ValidateKey(key)) + { + // test_tracestate_key_illegal_characters in https://github.com/w3c/trace-context/blob/master/test/test.py + // test_tracestate_key_length_limit + // test_tracestate_key_illegal_vendor_format + tracestateResult = string.Empty; + return false; + } - var value = listMember.Slice(keyLength + 1); - if (!ValidateValue(value)) - { - // test_tracestate_value_illegal_characters - return false; - } + var value = listMember.Slice(keyLength + 1); + if (!ValidateValue(value)) + { + // test_tracestate_value_illegal_characters + tracestateResult = string.Empty; + return false; + } - // ValidateKey() call above has ensured the key does not contain upper case letters. - if (!keySet.Add(key.ToString())) - { - // test_tracestate_duplicated_keys - return false; - } + // ValidateKey() call above has ensured the key does not contain upper case letters. + if (!keySet.Add(key.ToString())) + { + // test_tracestate_duplicated_keys + tracestateResult = string.Empty; + return false; + } - if (result.Length > 0) - { - result.Append(','); - } + if (result.Length > 0) + { + result.Append(','); + } #if NET - result.Append(listMember); + result.Append(listMember); #else - result.Append(listMember.ToString()); + result.Append(listMember.ToString()); #endif + } + } + + tracestateResult = result.ToString(); + return true; + } + + private static bool TryExtractSingleTracestate(string tracestate, out string tracestateResult) + { + tracestateResult = string.Empty; + + if (tracestate.Length == 0) + { + return true; + } + + var tracestateSpan = tracestate.AsSpan(); + + const int Limit = 32; + + Span memberStarts = stackalloc int[Limit]; + Span memberLengths = stackalloc int[Limit]; + Span keyLengths = stackalloc int[Limit]; + Span keyHashes = stackalloc int[Limit]; + + var memberCount = 0; + var totalLength = 0; + var normalized = false; + var begin = 0; + + while (begin < tracestateSpan.Length) + { + var end = begin; + while (end < tracestateSpan.Length && tracestateSpan[end] != ',') + { + end++; + } + + var memberStart = begin; + var memberEnd = end; + + while (memberStart < memberEnd && char.IsWhiteSpace(tracestateSpan[memberStart])) + { + memberStart++; + } + + while (memberEnd > memberStart && char.IsWhiteSpace(tracestateSpan[memberEnd - 1])) + { + memberEnd--; + } + + if (memberStart != begin || memberEnd != end) + { + normalized = true; + } + + var memberLength = memberEnd - memberStart; + if (memberLength > 0) + { + if (memberCount >= Limit) + { + return false; + } + + var listMember = tracestateSpan.Slice(memberStart, memberLength); + var keyLength = listMember.IndexOf('='); + if (keyLength == listMember.Length || keyLength == -1) + { + return false; + } + + var key = listMember.Slice(0, keyLength); + if (!ValidateKey(key)) + { + return false; + } + + var value = listMember.Slice(keyLength + 1); + if (!ValidateValue(value)) + { + return false; + } + + var useHashedDuplicateCheck = keyLength <= Limit; + var keyHash = 0; + if (useHashedDuplicateCheck) + { + keyHash = GetKeyHashCode(key); + for (var i = 0; i < memberCount; i++) + { + if (keyHashes[i] != keyHash || keyLengths[i] != keyLength) + { + continue; + } + + if (key.SequenceEqual(tracestateSpan.Slice(memberStarts[i], keyLength))) + { + return false; + } + } + } + else + { + for (var i = 0; i < memberCount; i++) + { + if (keyLengths[i] == keyLength && + key.SequenceEqual(tracestateSpan.Slice(memberStarts[i], keyLength))) + { + return false; + } + } } + + memberStarts[memberCount] = memberStart; + memberLengths[memberCount] = memberLength; + keyLengths[memberCount] = keyLength; + keyHashes[memberCount] = keyHash; + + memberCount++; + totalLength += memberLength; + } + else + { + normalized = true; + } + + begin = end + 1; + } + + if (!normalized && memberCount > 0 && totalLength + memberCount - 1 == tracestate.Length) + { + tracestateResult = tracestate; + return true; + } + + if (memberCount == 0) + { + return true; + } + + var result = new StringBuilder(totalLength + memberCount - 1); + for (var i = 0; i < memberCount; i++) + { + if (i > 0) + { + result.Append(','); } - tracestateResult = result.ToString(); +#if NET + result.Append(tracestateSpan.Slice(memberStarts[i], memberLengths[i])); +#else + result.Append(tracestate.Substring(memberStarts[i], memberLengths[i])); +#endif } + tracestateResult = result.ToString(); return true; } @@ -318,6 +542,72 @@ private static byte HexCharToByte(char c) ? (byte)(c - 'a' + 10) : throw new ArgumentOutOfRangeException(nameof(c), c, "Must be within: [0-9] or [a-f]"); + private static int GetKeyHashCode(ReadOnlySpan key) + { +#if NET + HashCode hash = default; + + for (var i = 0; i < key.Length; i++) + { + hash.Add(key[i]); + } + + return hash.ToHashCode(); +#else + unchecked + { + var hash = (int)2166136261; + for (var i = 0; i < key.Length; i++) + { + hash = (hash ^ key[i]) * 16777619; + } + + return hash; + } +#endif + } + + private static bool TryGetSingleValue(IEnumerable? values, out string value) + { + value = string.Empty; + + if (values == null) + { + return false; + } + + if (values is IList list) + { + if (list.Count != 1) + { + return false; + } + + value = list[0]; + return true; + } + + if (values is IReadOnlyList readOnlyList) + { + if (readOnlyList.Count != 1) + { + return false; + } + + value = readOnlyList[0]; + return true; + } + + using var enumerator = values.GetEnumerator(); + if (!enumerator.MoveNext()) + { + return false; + } + + value = enumerator.Current; + return !enumerator.MoveNext(); + } + private static bool ValidateKey(ReadOnlySpan key) { // This implementation follows Trace Context v1 which has W3C Recommendation. diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt index e69de29bb2d..5c7ce12865f 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +OpenTelemetry.Exporter.OtlpExportCompression +OpenTelemetry.Exporter.OtlpExportCompression.Gzip = 1 -> OpenTelemetry.Exporter.OtlpExportCompression +OpenTelemetry.Exporter.OtlpExportCompression.None = 0 -> OpenTelemetry.Exporter.OtlpExportCompression +OpenTelemetry.Exporter.OtlpExporterOptions.Compression.get -> OpenTelemetry.Exporter.OtlpExportCompression +OpenTelemetry.Exporter.OtlpExporterOptions.Compression.set -> void diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index d3b5a139c17..32493699397 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -7,6 +7,11 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Added opt-in support for gzip compression. Compression can be configured + programmatically via the new `OtlpExporterOptions.Compression` property, + or through the environment variables such as `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`. + ([#3961](https://github.com/open-telemetry/opentelemetry-dotnet/issues/3961)) + ## 1.15.2 Released 2026-Apr-08 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/IOtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/IOtlpExporterOptions.cs index d6402bc85a8..5be4f317a92 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/IOtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/IOtlpExporterOptions.cs @@ -103,4 +103,9 @@ internal interface IOtlpExporterOptions /// /// Func HttpClientFactory { get; set; } + + /// + /// Gets or sets the compression method to use when sending telemetry. + /// + OtlpExportCompression Compression { get; set; } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs index 2d66a1dc463..c83aa933f76 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs @@ -48,6 +48,7 @@ protected OtlpExportClient(OtlpExporterOptions options, HttpClient httpClient, s this.Endpoint = new UriBuilder(exporterEndpoint).Uri; this.Headers = options.GetHeaders>((d, k, v) => d.Add(k, v)); this.HttpClient = httpClient; + this.CompressionEnabled = options.Compression == OtlpExportCompression.Gzip; } internal HttpClient HttpClient { get; } @@ -56,6 +57,8 @@ protected OtlpExportClient(OtlpExporterOptions options, HttpClient httpClient, s internal IReadOnlyDictionary Headers { get; } + internal bool CompressionEnabled { get; } + internal abstract MediaTypeHeaderValue MediaTypeHeader { get; } internal virtual bool RequireHttp2 => false; @@ -189,14 +192,25 @@ protected HttpRequestMessage CreateHttpRequest(byte[] buffer, int contentLength) request.Headers.Add(header.Key, header.Value); } - // TODO: Support compression. - - request.Content = new ByteArrayContent(buffer, 0, contentLength); - request.Content.Headers.ContentType = this.MediaTypeHeader; + request.Content = this.CreateHttpContent(buffer, contentLength); return request; } + /// + /// Creates the for a request. Override in subclasses to + /// customise content creation (e.g. to apply compression). + /// + /// The serialized protobuf payload buffer. + /// The number of bytes within that make up the message. + /// An representing the export payload. + protected virtual HttpContent CreateHttpContent(byte[] buffer, int contentLength) + { + var content = new ByteArrayContent(buffer, 0, contentLength); + content.Headers.ContentType = this.MediaTypeHeader; + return content; + } + protected HttpResponseMessage SendHttpRequest(HttpRequestMessage request, CancellationToken cancellationToken) => #if NET // Note: SendAsync must be used with HTTP/2 because synchronous send is diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index b0678278625..7e9f7848181 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -4,8 +4,11 @@ #if NETFRAMEWORK using System.Net.Http; #endif +using System.Buffers.Binary; using System.Diagnostics.Tracing; +using System.IO.Compression; using System.Net.Http.Headers; +using System.Net.Sockets; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient.Grpc; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; @@ -14,6 +17,12 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClie internal sealed class OtlpGrpcExportClient : OtlpExportClient { public const string GrpcStatusDetailsHeader = "grpc-status-details-bin"; + + // A gRPC message frame header is 5 bytes: + // byte 0 - Compression flag (0 = not compressed, 1 = compressed). + // bytes 1-4 - Message length in big-endian format. + private const int GrpcMessageHeaderSize = 5; + private static readonly ExportClientHttpResponse SuccessExportResponse = new(success: true, deadlineUtc: default, response: null, exception: null); private static readonly MediaTypeHeaderValue MediaHeaderValue = new("application/grpc"); @@ -25,6 +34,10 @@ private static readonly ExportClientGrpcResponse DefaultExceptionExportClientGrp status: null, grpcStatusDetailsHeader: null); +#if !NET + private static readonly byte[] GrpcFrameHeader = [0, 0, 0, 0, 0]; +#endif + public OtlpGrpcExportClient(OtlpExporterOptions options, HttpClient httpClient, string signalPath) : base(options, httpClient, signalPath) { @@ -34,6 +47,11 @@ public OtlpGrpcExportClient(OtlpExporterOptions options, HttpClient httpClient, internal override bool RequireHttp2 => true; +#if NET + // See https://vcsjones.dev/csharp-readonly-span-bytes-static/ + private static ReadOnlySpan GrpcFrameHeader => [0, 0, 0, 0, 0]; +#endif + /// public override ExportClientResponse SendExportRequest(byte[] buffer, int contentLength, DateTime deadlineUtc, CancellationToken cancellationToken = default) { @@ -47,6 +65,12 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten // A missing TE header results in servers aborting the gRPC call. httpRequest.Headers.TryAddWithoutValidation("TE", "trailers"); + if (this.CompressionEnabled) + { + httpRequest.Headers.Remove("grpc-encoding"); + httpRequest.Headers.TryAddWithoutValidation("grpc-encoding", "gzip"); + } + httpResponse = this.SendHttpRequest(httpRequest, cancellationToken); httpResponse.EnsureSuccessStatusCode(); @@ -170,10 +194,48 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten } } + protected override HttpContent CreateHttpContent(byte[] buffer, int contentLength) + { + if (!this.CompressionEnabled) + { + return base.CreateHttpContent(buffer, contentLength); + } + + // Build a gzip-compressed gRPC message frame: + // byte 0 - Compression flag = 1 (gzip). + // bytes 1-4 - Compressed payload length in big-endian format. + // bytes 5+ - Gzip-compressed protobuf payload. + var compressedStream = new MemoryStream(); + + // Reserve space for the gRPC frame header. +#if NET + compressedStream.Write(GrpcFrameHeader); +#else + compressedStream.Write(GrpcFrameHeader, 0, GrpcFrameHeader.Length); +#endif + + using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) + { + gzipStream.Write(buffer, GrpcMessageHeaderSize, contentLength - GrpcMessageHeaderSize); + } + + var compressedPayloadLength = (uint)(compressedStream.Length - GrpcMessageHeaderSize); + + // Write the gRPC frame header: compression flag + big-endian payload length. + compressedStream.Position = 0; + compressedStream.WriteByte(1); + + var lengthBytes = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(lengthBytes, compressedPayloadLength); + compressedStream.Write(lengthBytes, 0, 4); + + compressedStream.Position = 0; + + var content = new StreamContent(compressedStream); + content.Headers.ContentType = this.MediaTypeHeader; + return content; + } + private static bool IsTransientNetworkError(HttpRequestException ex) => - ex.InnerException is System.Net.Sockets.SocketException socketEx - && (socketEx.SocketErrorCode == System.Net.Sockets.SocketError.TimedOut - || socketEx.SocketErrorCode == System.Net.Sockets.SocketError.ConnectionReset - || socketEx.SocketErrorCode == System.Net.Sockets.SocketError.HostUnreachable - || socketEx.SocketErrorCode == System.Net.Sockets.SocketError.ConnectionRefused); + ex.InnerException is SocketException { SocketErrorCode: SocketError.TimedOut or SocketError.ConnectionReset or SocketError.HostUnreachable or SocketError.ConnectionRefused }; } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs index b6980966674..f0da95eec76 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs @@ -5,6 +5,7 @@ using System.Net.Http; #endif using System.Diagnostics.Tracing; +using System.IO.Compression; using System.Net.Http.Headers; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; @@ -54,4 +55,27 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten return new ExportClientHttpResponse(success: false, deadlineUtc: deadlineUtc, response: null, exception: ex); } } + + protected override HttpContent CreateHttpContent(byte[] buffer, int contentLength) + { + if (!this.CompressionEnabled) + { + return base.CreateHttpContent(buffer, contentLength); + } + + var compressedStream = new MemoryStream(); + using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) + { + gzipStream.Write(buffer, 0, contentLength); + } + + compressedStream.Position = 0; + + var content = new StreamContent(compressedStream); + + content.Headers.ContentType = this.MediaTypeHeader; + content.Headers.Add("Content-Encoding", "gzip"); + + return content; + } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs index bb7e4c8c3f3..ea45c8cb72d 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs @@ -15,16 +15,19 @@ internal static class OtlpSpecConfigDefinitions public const string DefaultHeadersEnvVarName = "OTEL_EXPORTER_OTLP_HEADERS"; public const string DefaultTimeoutEnvVarName = "OTEL_EXPORTER_OTLP_TIMEOUT"; public const string DefaultProtocolEnvVarName = "OTEL_EXPORTER_OTLP_PROTOCOL"; + public const string DefaultCompressionEnvVarName = "OTEL_EXPORTER_OTLP_COMPRESSION"; public const string LogsEndpointEnvVarName = "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"; public const string LogsHeadersEnvVarName = "OTEL_EXPORTER_OTLP_LOGS_HEADERS"; public const string LogsTimeoutEnvVarName = "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT"; public const string LogsProtocolEnvVarName = "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"; + public const string LogsCompressionEnvVarName = "OTEL_EXPORTER_OTLP_LOGS_COMPRESSION"; public const string MetricsEndpointEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"; public const string MetricsHeadersEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_HEADERS"; public const string MetricsTimeoutEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT"; public const string MetricsProtocolEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"; + public const string MetricsCompressionEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_COMPRESSION"; public const string MetricsTemporalityPreferenceEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"; public const string MetricsDefaultHistogramAggregationEnvVarName = "OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION"; @@ -32,6 +35,7 @@ internal static class OtlpSpecConfigDefinitions public const string TracesHeadersEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_HEADERS"; public const string TracesTimeoutEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT"; public const string TracesProtocolEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"; + public const string TracesCompressionEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION"; // mTLS certificate environment variables public const string CertificateEnvVarName = "OTEL_EXPORTER_OTLP_CERTIFICATE"; diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs new file mode 100644 index 00000000000..c16c054ff00 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs @@ -0,0 +1,23 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Exporter; + +/// +/// Compression methods supported by the OTLP exporter. +/// +/// +/// Specification: . +/// +public enum OtlpExportCompression +{ + /// + /// No compression. + /// + None = 0, + + /// + /// Compress with GZip. + /// + Gzip = 1, +} diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 430929f40d3..4055a392503 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -43,6 +43,7 @@ public class OtlpExporterOptions : IOtlpExporterOptions private Uri? endpoint; private int? timeoutMilliseconds; private Func? httpClientFactory; + private OtlpExportCompression? compression; /// /// Initializes a new instance of the class. @@ -134,6 +135,13 @@ public OtlpExportProtocol Protocol set => this.protocol = value; } + /// + public OtlpExportCompression Compression + { + get => this.compression ?? OtlpExportCompression.None; + set => this.compression = value; + } + /// /// Gets or sets a custom user agent identifier. /// This will be prepended to the default user agent string. @@ -187,7 +195,26 @@ internal bool HasData => this.protocol.HasValue || this.endpoint != null || this.timeoutMilliseconds.HasValue - || this.httpClientFactory != null; + || this.httpClientFactory != null + || this.compression.HasValue; + + internal static bool TryParseCompression(string value, out OtlpExportCompression result) + { + switch (value.Trim().ToUpperInvariant()) + { + case "NONE": + result = OtlpExportCompression.None; + return true; + + case "GZIP": + result = OtlpExportCompression.Gzip; + return true; + + default: + result = default; + return false; + } + } internal static OtlpExporterOptions CreateOtlpExporterOptions( IServiceProvider serviceProvider, @@ -203,7 +230,8 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( bool appendSignalPathToEndpoint, string protocolEnvVarKey, string headersEnvVarKey, - string timeoutEnvVarKey) + string timeoutEnvVarKey, + string compressionEnvVarKey) { if (configuration.TryGetUriValue(OpenTelemetryProtocolExporterEventSource.Log, endpointEnvVarKey, out var endpoint)) { @@ -229,6 +257,15 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( { this.TimeoutMilliseconds = timeout; } + + if (configuration.TryGetValue( + OpenTelemetryProtocolExporterEventSource.Log, + compressionEnvVarKey, + TryParseCompression, + out var compression)) + { + this.Compression = compression; + } } internal OtlpExporterOptions ApplyDefaults(OtlpExporterOptions defaultExporterOptions) @@ -247,6 +284,8 @@ internal OtlpExporterOptions ApplyDefaults(OtlpExporterOptions defaultExporterOp this.httpClientFactory ??= defaultExporterOptions.httpClientFactory; + this.compression ??= defaultExporterOptions.compression; + return this; } @@ -266,7 +305,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: true, OtlpSpecConfigDefinitions.DefaultProtocolEnvVarName, OtlpSpecConfigDefinitions.DefaultHeadersEnvVarName, - OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName); + OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName, + OtlpSpecConfigDefinitions.DefaultCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Logs) { @@ -276,7 +316,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.LogsProtocolEnvVarName, OtlpSpecConfigDefinitions.LogsHeadersEnvVarName, - OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName); + OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName, + OtlpSpecConfigDefinitions.LogsCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Metrics) { @@ -286,7 +327,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.MetricsProtocolEnvVarName, OtlpSpecConfigDefinitions.MetricsHeadersEnvVarName, - OtlpSpecConfigDefinitions.MetricsTimeoutEnvVarName); + OtlpSpecConfigDefinitions.MetricsTimeoutEnvVarName, + OtlpSpecConfigDefinitions.MetricsCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Traces) { @@ -296,7 +338,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.TracesProtocolEnvVarName, OtlpSpecConfigDefinitions.TracesHeadersEnvVarName, - OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName); + OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName, + OtlpSpecConfigDefinitions.TracesCompressionEnvVarName); } else { diff --git a/src/OpenTelemetry/SuppressInstrumentationScope.cs b/src/OpenTelemetry/SuppressInstrumentationScope.cs index f568c0053f6..118f6d93284 100644 --- a/src/OpenTelemetry/SuppressInstrumentationScope.cs +++ b/src/OpenTelemetry/SuppressInstrumentationScope.cs @@ -11,22 +11,33 @@ namespace OpenTelemetry; /// public sealed class SuppressInstrumentationScope : IDisposable { + private const int BeginPoolMaxSize = 8; + // An integer value which controls whether instrumentation should be suppressed (disabled). // * null: instrumentation is not suppressed // * Depth = [int.MinValue, -1]: instrumentation is always suppressed // * Depth = [1, int.MaxValue]: instrumentation is suppressed in a reference-counting mode private static readonly RuntimeContextSlot Slot = RuntimeContext.RegisterSlot("otel.suppress_instrumentation"); + private static readonly NoOpDisposable Noop = new(); + + // Thread-local pool for Begin() scopes. Bounded to avoid unbounded growth. + [ThreadStatic] + private static Stack? beginPool; + + // Thread-local cached scope for the Enter() path. Reused whenever Enter() is + // called with no active scope, eliminating the allocation on the hot activity path. + [ThreadStatic] + private static SuppressInstrumentationScope? enterCache; + #pragma warning disable CA2213 // Disposable fields should be disposed - private readonly SuppressInstrumentationScope? previousScope; + private SuppressInstrumentationScope? previousScope; #pragma warning restore CA2213 // Disposable fields should be disposed private bool disposed; + private bool pooled; - internal SuppressInstrumentationScope(bool value = true) + private SuppressInstrumentationScope() { - this.previousScope = Slot.Get(); - this.Depth = value ? -1 : 0; - Slot.Set(this); } internal static bool IsSuppressed => (Slot.Get()?.Depth ?? 0) != 0; @@ -57,7 +68,24 @@ internal SuppressInstrumentationScope(bool value = true) /// public static IDisposable Begin(bool value = true) { - return new SuppressInstrumentationScope(value); + // When already in always-suppress mode and asked to suppress again, the + // slot state is unchanged - return a no-op disposable to skip both the allocation and + // the AsyncLocal write. + if (value && Slot.Get()?.Depth < 0) + { + return Noop; + } + + // Rent a scope from the thread-local pool, or allocate a fresh one. + var pool = beginPool; + var scope = pool is { Count: > 0 } ? pool.Pop() : new SuppressInstrumentationScope(); + scope.Initialize(value); + + // Return a token wrapper rather than the scope itself. The token nulls its + // scope reference after the first Dispose(), so any stale reference to the + // token becomes a safe no-op even if the underlying scope has since been + // re-rented from the pool and re-initialized for a different caller. + return new ScopeToken(scope); } /// @@ -74,14 +102,11 @@ public static int Enter() if (currentScope == null) { - Slot.Set( -#pragma warning disable CA2000 // Dispose objects before losing scope - new SuppressInstrumentationScope() - { - Depth = 1, - }); -#pragma warning restore CA2000 // Dispose objects before losing scope - + // Reuse the thread-local cached scope to avoid allocating + var scope = enterCache ??= new SuppressInstrumentationScope(); + scope.previousScope = null; + scope.Depth = 1; + Slot.Set(scope); return 1; } @@ -102,6 +127,17 @@ public void Dispose() { Slot.Set(this.previousScope); this.disposed = true; + + // Return the scope to the thread-local pool for reuse by future Begin() calls. + if (this.pooled) + { + var pool = beginPool ??= new Stack(); + if (pool.Count < BeginPoolMaxSize) + { + this.previousScope = null; // Release the reference before returning to pool + pool.Push(this); + } + } } } @@ -151,4 +187,49 @@ internal static int DecrementIfTriggered() return currentDepth; } + + private void Initialize(bool value) + { + this.previousScope = Slot.Get(); + + this.Depth = value ? -1 : 0; + this.disposed = false; + this.pooled = true; + + Slot.Set(this); + } + + /// + /// Thin wrapper returned by Begin(). Holds a nullable reference to the underlying + /// scope and clears it on first Dispose(), making all subsequent calls a safe no-op. + /// This prevents a stale IDisposable reference from accidentally affecting a scope + /// instance that has been returned to the pool and re-rented for a different caller. + /// + private sealed class ScopeToken : IDisposable + { + private SuppressInstrumentationScope? scope; + + internal ScopeToken(SuppressInstrumentationScope scope) + { + this.scope = scope; + } + + public void Dispose() + { + var s = this.scope; + if (s is not null) + { + this.scope = null; + s.Dispose(); + } + } + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() + { + // No-op + } + } } diff --git a/src/OpenTelemetry/Trace/Sampler/SamplingResult.cs b/src/OpenTelemetry/Trace/Sampler/SamplingResult.cs index 21e5f92bee5..a65b3b67f92 100644 --- a/src/OpenTelemetry/Trace/Sampler/SamplingResult.cs +++ b/src/OpenTelemetry/Trace/Sampler/SamplingResult.cs @@ -8,6 +8,10 @@ namespace OpenTelemetry.Trace; /// public readonly struct SamplingResult : IEquatable { + // Null when no attributes were supplied; avoids a GetEnumerator() call (and enumerator boxing) + // on the hot path inside TracerProviderSdk when the sampler returns no attributes. + private readonly IEnumerable>? attributesField; + /// /// Initializes a new instance of the struct. /// @@ -61,7 +65,7 @@ public SamplingResult(SamplingDecision decision, IEnumerable /// Gets a map of attributes associated with the sampling decision. /// - public IEnumerable> Attributes { get; } + public IEnumerable> Attributes => this.attributesField ?? []; /// /// Gets the tracestate. /// public string? TraceStateString { get; } + // Internal accessor used by TracerProviderSdk to skip iteration entirely when null. + internal IEnumerable>? AttributesOrNull => this.attributesField; + /// /// Compare two for equality. /// diff --git a/src/OpenTelemetry/Trace/TracerProviderSdk.cs b/src/OpenTelemetry/Trace/TracerProviderSdk.cs index be47ed5cf58..5c28ad99bf8 100644 --- a/src/OpenTelemetry/Trace/TracerProviderSdk.cs +++ b/src/OpenTelemetry/Trace/TracerProviderSdk.cs @@ -222,7 +222,7 @@ internal TracerProviderSdk( if (this.Sampler is AlwaysOnSampler) { - activityListener.Sample = (ref options) => + activityListener.Sample = static (ref _) => !Sdk.SuppressInstrumentation ? ActivitySamplingResult.AllDataAndRecorded : ActivitySamplingResult.None; this.getRequestedDataAction = this.RunGetRequestedDataAlwaysOnSampler; } @@ -479,9 +479,22 @@ private static ActivitySamplingResult ComputeActivitySamplingResult( if (activitySamplingResult > ActivitySamplingResult.PropagationData) { - foreach (var att in samplingResult.Attributes) + if (samplingResult.AttributesOrNull is { } attributes) { - options.SamplingTags.Add(att.Key, att.Value); + if (attributes is KeyValuePair[] array) + { + for (int i = 0; i < array.Length; i++) + { + options.SamplingTags.Add(array[i].Key, array[i].Value); + } + } + else + { + foreach (var att in attributes) + { + options.SamplingTags.Add(att.Key, att.Value); + } + } } } @@ -575,9 +588,22 @@ private void RunGetRequestedDataOtherSampler(Activity activity) if (samplingResult.Decision != SamplingDecision.Drop) { - foreach (var att in samplingResult.Attributes) + if (samplingResult.AttributesOrNull is { } attributes) { - activity.SetTag(att.Key, att.Value); + if (attributes is KeyValuePair[] array) + { + for (int i = 0; i < array.Length; i++) + { + activity.SetTag(array[i].Key, array[i].Value); + } + } + else + { + foreach (var att in attributes) + { + activity.SetTag(att.Key, att.Value); + } + } } } diff --git a/test/Benchmarks/Context/Propagation/BaggagePropagatorBenchmarks.cs b/test/Benchmarks/Context/Propagation/BaggagePropagatorBenchmarks.cs new file mode 100644 index 00000000000..ee82ca152c0 --- /dev/null +++ b/test/Benchmarks/Context/Propagation/BaggagePropagatorBenchmarks.cs @@ -0,0 +1,67 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using BenchmarkDotNet.Attributes; +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; + +namespace Benchmarks.Context.Propagation; + +[MemoryDiagnoser] +public class BaggagePropagatorBenchmarks +{ + private static readonly BaggagePropagator Propagator = new(); + + private static readonly Func, string, IEnumerable> Getter = + static (carrier, name) => carrier.TryGetValue(name, out var value) ? [value] : []; + + private static readonly Action, string, string> Setter = + static (carrier, name, value) => carrier[name] = value; + + /// Gets or sets the number of baggage entries used in each benchmark run. + [Params(1, 5, 20)] + public int ItemCount { get; set; } + + /// Gets or sets a value indicating whether keys and values contain characters that require URL-encoding. + [Params(false, true)] + public bool UseSpecialChars { get; set; } + + public Dictionary ExtractCarrier { get; private set; } = []; + + public Dictionary InjectCarrier { get; private set; } = []; + + public PropagationContext InjectContext { get; private set; } + + [GlobalSetup] + public void Setup() + { + IEnumerable<(string Key, string Value)> Items() => + Enumerable.Range(0, this.ItemCount).Select(i => + this.UseSpecialChars + ? ($"key {i}", $"value {i} !@#$%^&*()") + : ($"key{i}", $"value{i}")); + + var baggageHeader = string.Join(",", Items().Select(p => + $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + + this.ExtractCarrier = new Dictionary + { + ["baggage"] = baggageHeader, + }; + + var baggageDict = Items().ToDictionary(p => p.Key, p => p.Value); + this.InjectContext = new PropagationContext(default, Baggage.Create(baggageDict)); + this.InjectCarrier = []; + } + + [Benchmark] + public PropagationContext Extract() => + Propagator.Extract(default, this.ExtractCarrier, Getter); + + [Benchmark] + public void Inject() + { + this.InjectCarrier.Clear(); + Propagator.Inject(this.InjectContext, this.InjectCarrier, Setter); + } +} diff --git a/test/Benchmarks/Context/Propagation/TraceContextPropagatorBenchmarks.cs b/test/Benchmarks/Context/Propagation/TraceContextPropagatorBenchmarks.cs index aea350fd668..4ade256636d 100644 --- a/test/Benchmarks/Context/Propagation/TraceContextPropagatorBenchmarks.cs +++ b/test/Benchmarks/Context/Propagation/TraceContextPropagatorBenchmarks.cs @@ -16,15 +16,8 @@ public class TraceContextPropagatorBenchmarks private static readonly Random Random = new(455946); private static readonly TraceContextPropagator TraceContextPropagator = new(); - private static readonly Func, string, IEnumerable> Getter = (headers, name) => - { - if (headers.TryGetValue(name, out var value)) - { - return [value]; - } - - return []; - }; + private static readonly Func, string, IEnumerable> Getter = + static (headers, name) => headers.TryGetValue(name, out var value) ? [value] : []; [Params(true, false)] public bool LongListMember { get; set; } diff --git a/test/Benchmarks/SuppressInstrumentationScopeBenchmarks.cs b/test/Benchmarks/SuppressInstrumentationScopeBenchmarks.cs index a757ef697eb..afa91bcdc42 100644 --- a/test/Benchmarks/SuppressInstrumentationScopeBenchmarks.cs +++ b/test/Benchmarks/SuppressInstrumentationScopeBenchmarks.cs @@ -5,6 +5,7 @@ namespace OpenTelemetry.Benchmarks; +[MemoryDiagnoser] public class SuppressInstrumentationScopeBenchmarks { [Benchmark] diff --git a/test/Benchmarks/Trace/SamplingResultBenchmarks.cs b/test/Benchmarks/Trace/SamplingResultBenchmarks.cs new file mode 100644 index 00000000000..861198ad6e7 --- /dev/null +++ b/test/Benchmarks/Trace/SamplingResultBenchmarks.cs @@ -0,0 +1,134 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using BenchmarkDotNet.Attributes; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace Benchmarks.Trace; + +#pragma warning disable CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +[MemoryDiagnoser] +public class SamplingResultBenchmarks +#pragma warning restore CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +{ + private static readonly KeyValuePair[] SamplingAttributes = + [ + new("sampling.priority", 1), + new("sampling.rule", "always"), + ]; + + private ActivitySource? sourceNoAttributes; + private ActivitySource? sourceWithAttributeArray; + private ActivitySource? sourceWithAttributeList; + private ActivitySource? sourceDrop; + private ActivitySource? sourceParentBased; + + private ActivityContext sampledRemoteParent; + + private TracerProvider? providerNoAttributes; + private TracerProvider? providerWithAttributeArray; + private TracerProvider? providerWithAttributeList; + private TracerProvider? providerDrop; + private TracerProvider? providerParentBased; + + [GlobalSetup] + public void Setup() + { + this.sourceNoAttributes = new ActivitySource("SamplingResult.NoAttributes"); + this.sourceWithAttributeArray = new ActivitySource("SamplingResult.WithAttributeArray"); + this.sourceWithAttributeList = new ActivitySource("SamplingResult.WithAttributeList"); + this.sourceDrop = new ActivitySource("SamplingResult.Drop"); + this.sourceParentBased = new ActivitySource("SamplingResult.ParentBased"); + + this.sampledRemoteParent = new ActivityContext( + ActivityTraceId.CreateRandom(), + ActivitySpanId.CreateRandom(), + ActivityTraceFlags.Recorded, + traceState: null, + isRemote: true); + + // Sampler returns RecordAndSample with no attributes - the common case. + this.providerNoAttributes = Sdk.CreateTracerProviderBuilder() + .AddSource(this.sourceNoAttributes.Name) + .SetSampler(new DelegateSampler(_ => new SamplingResult(SamplingDecision.RecordAndSample))) + .Build(); + + // Sampler returns attributes as a T[] - exercises the array fast-path. + this.providerWithAttributeArray = Sdk.CreateTracerProviderBuilder() + .AddSource(this.sourceWithAttributeArray.Name) + .SetSampler(new DelegateSampler(_ => new SamplingResult(SamplingDecision.RecordAndSample, SamplingAttributes))) + .Build(); + + // Sampler returns attributes as a List - exercises the IEnumerable fallback path. + this.providerWithAttributeList = Sdk.CreateTracerProviderBuilder() + .AddSource(this.sourceWithAttributeList.Name) + .SetSampler(new DelegateSampler(_ => new SamplingResult(SamplingDecision.RecordAndSample, [.. SamplingAttributes]))) + .Build(); + + // Sampler drops the span - attribute loop is never entered. + this.providerDrop = Sdk.CreateTracerProviderBuilder() + .AddSource(this.sourceDrop.Name) + .SetSampler(new DelegateSampler(_ => new SamplingResult(SamplingDecision.Drop))) + .Build(); + + // ParentBasedSampler with AlwaysOnSampler root - realistic production default. + this.providerParentBased = Sdk.CreateTracerProviderBuilder() + .AddSource(this.sourceParentBased.Name) + .SetSampler(new ParentBasedSampler(new AlwaysOnSampler())) + .Build(); + } + + [GlobalCleanup] + public void Cleanup() + { + this.sourceNoAttributes?.Dispose(); + this.sourceWithAttributeArray?.Dispose(); + this.sourceWithAttributeList?.Dispose(); + this.sourceDrop?.Dispose(); + this.sourceParentBased?.Dispose(); + + this.providerNoAttributes?.Dispose(); + this.providerWithAttributeArray?.Dispose(); + this.providerWithAttributeList?.Dispose(); + this.providerDrop?.Dispose(); + this.providerParentBased?.Dispose(); + } + + [Benchmark(Baseline = true)] + public void NoAttributes() + { + using var activity = this.sourceNoAttributes!.StartActivity("Benchmark", ActivityKind.Server, this.sampledRemoteParent); + } + + [Benchmark] + public void WithAttributeArray() + { + using var activity = this.sourceWithAttributeArray!.StartActivity("Benchmark", ActivityKind.Server, this.sampledRemoteParent); + } + + [Benchmark] + public void WithAttributeList() + { + using var activity = this.sourceWithAttributeList!.StartActivity("Benchmark", ActivityKind.Server, this.sampledRemoteParent); + } + + [Benchmark] + public void Drop() + { + using var activity = this.sourceDrop!.StartActivity("Benchmark", ActivityKind.Server, this.sampledRemoteParent); + } + + [Benchmark] + public void ParentBasedSampled() + { + using var activity = this.sourceParentBased!.StartActivity("Benchmark", ActivityKind.Server, this.sampledRemoteParent); + } + + private sealed class DelegateSampler(Func sample) : Sampler + { + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) + => sample(samplingParameters); + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index fd56c65bb8a..6fc2be67a03 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs @@ -13,10 +13,8 @@ public OtlpExporterOptionsTests() OtlpSpecConfigDefinitionTests.ClearEnvVars(); } - public void Dispose() - { + public void Dispose() => OtlpSpecConfigDefinitionTests.ClearEnvVars(); - } [Fact] public void OtlpExporterOptions_Defaults() @@ -96,9 +94,10 @@ public void OtlpExporterOptions_InvalidEnvironmentVariableOverride() { var values = new Dictionary { + ["CompressionWithInvalidValue"] = "invalid", ["EndpointWithInvalidValue"] = "invalid", - ["TimeoutWithInvalidValue"] = "invalid", ["ProtocolWithInvalidValue"] = "invalid", + ["TimeoutWithInvalidValue"] = "invalid", }; var configuration = new ConfigurationBuilder() @@ -113,7 +112,8 @@ public void OtlpExporterOptions_InvalidEnvironmentVariableOverride() appendSignalPathToEndpoint: true, "ProtocolWithInvalidValue", "NoopHeaders", - "TimeoutWithInvalidValue"); + "TimeoutWithInvalidValue", + "CompressionWithInvalidValue"); #if NETFRAMEWORK || NETSTANDARD2_0 Assert.Equal(new Uri(OtlpExporterOptions.DefaultHttpEndpoint), options.Endpoint); @@ -124,6 +124,7 @@ public void OtlpExporterOptions_InvalidEnvironmentVariableOverride() Assert.Equal(10000, options.TimeoutMilliseconds); Assert.Equal(OtlpExporterOptions.DefaultOtlpExportProtocol, options.Protocol); Assert.Null(options.Headers); + Assert.Equal(OtlpExportCompression.None, options.Compression); } [Fact] @@ -131,10 +132,11 @@ public void OtlpExporterOptions_SetterOverridesEnvironmentVariable() { var values = new Dictionary { + ["Compression"] = "GZIP", ["Endpoint"] = "http://test:8888", - ["Timeout"] = "2000", - ["Protocol"] = "grpc", ["Headers"] = "A=2,B=3", + ["Protocol"] = "grpc", + ["Timeout"] = "2000", }; var configuration = new ConfigurationBuilder() @@ -149,7 +151,8 @@ public void OtlpExporterOptions_SetterOverridesEnvironmentVariable() appendSignalPathToEndpoint: true, "Protocol", "Headers", - "Timeout"); + "Timeout", + "Compression"); options.Endpoint = new Uri("http://localhost:200"); options.Headers = "C=3"; @@ -161,6 +164,41 @@ public void OtlpExporterOptions_SetterOverridesEnvironmentVariable() Assert.Equal(40000, options.TimeoutMilliseconds); Assert.Equal(OtlpExportProtocol.HttpProtobuf, options.Protocol); Assert.False(options.AppendSignalPathToEndpoint); + Assert.Equal(OtlpExportCompression.Gzip, options.Compression); + } + + [Theory] + [InlineData("", OtlpExportCompression.None)] + [InlineData("foo", OtlpExportCompression.None)] + [InlineData("gzip", OtlpExportCompression.Gzip)] + [InlineData("GZip", OtlpExportCompression.Gzip)] + [InlineData("GZIP", OtlpExportCompression.Gzip)] + [InlineData("none", OtlpExportCompression.None)] + [InlineData("None", OtlpExportCompression.None)] + [InlineData("NONE", OtlpExportCompression.None)] + public void OtlpExporterOptions_AppliesCompressionFromEnvironment(string value, OtlpExportCompression expected) + { + var values = new Dictionary + { + ["Compression"] = value, + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + var options = new OtlpExporterOptions(); + + options.ApplyConfigurationUsingSpecificationEnvVars( + configuration, + "Endpoint", + appendSignalPathToEndpoint: true, + "Protocol", + "Headers", + "Timeout", + "Compression"); + + Assert.Equal(expected, options.Compression); } [Fact] diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs new file mode 100644 index 00000000000..6b0118b0190 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs @@ -0,0 +1,171 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET +using System.Net.Http; +#endif +using System.Buffers.Binary; +using System.IO.Compression; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests.Implementation.ExportClient; + +public class OtlpGrpcExportClientTests +{ + private const int GrpcHeaderSize = 5; + + [Fact] + public void SendExportRequest_GrpcExport_NoCompression_ContentMatchesOriginalPayload() + { + var protobufPayload = "grpc test payload"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.None, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + Assert.True(content.Length >= GrpcHeaderSize, "No content was written."); + + Assert.Equal(0, content[0]); + + var declaredLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + Assert.Equal(protobufPayload.Length, declaredLength); + Assert.Equal(protobufPayload, content.AsSpan(GrpcHeaderSize, protobufPayload.Length).ToArray()); + + Assert.NotNull(testHandler.CapturedRequestHeaders); + Assert.DoesNotContain(testHandler.CapturedRequestHeaders, h => h.Key == "grpc-encoding"); + } + + [Fact] + public void SendExportRequest_GrpcExport_GzipCompression_FrameLengthMatchesCompressedPayload() + { + var protobufPayload = "grpc test payload for compression"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + Assert.True(content.Length >= GrpcHeaderSize, "No content was written."); + + Assert.Equal(1, testHandler.CapturedRequestBytes[0]); + + var compressedLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + Assert.Equal(content.Length - GrpcHeaderSize, compressedLength); + } + + [Fact] + public void SendExportRequest_GrpcExport_GzipCompression_PayloadDecompressesToOriginalProtobuf() + { + var protobufPayload = "grpc test payload for compression"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + var compressedLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + var decompressed = Decompress(content.AsSpan(GrpcHeaderSize, compressedLength).ToArray()); + Assert.Equal(protobufPayload, decompressed); + + Assert.NotNull(testHandler.CapturedRequestHeaders); + Assert.Contains( + testHandler.CapturedRequestHeaders, + h => h.Key == "grpc-encoding" && h.Value.Contains("gzip")); + } + + private static byte[] BuildGrpcFrame(byte[] protobufPayload) + { + var frame = new byte[GrpcHeaderSize + protobufPayload.Length]; + frame[0] = 0; + + BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(1, 4), (uint)protobufPayload.Length); + + protobufPayload.CopyTo(frame, GrpcHeaderSize); + + return frame; + } + + private static byte[] Decompress(byte[] compressed) + { + using var input = new MemoryStream(compressed); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + + gzip.CopyTo(output); + + return output.ToArray(); + } + + private sealed class TestGrpcMessageHandler : HttpMessageHandler + { + public byte[]? CapturedRequestBytes { get; private set; } + + public List>>? CapturedRequestHeaders { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(this.Handle(request, cancellationToken)); + +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + => this.Handle(request, cancellationToken); +#endif + + private HttpResponseMessage Handle(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.CapturedRequestHeaders = [.. request.Headers]; + +#if NET + this.CapturedRequestBytes = request.Content!.ReadAsByteArrayAsync(cancellationToken).Result; +#else + this.CapturedRequestBytes = request.Content.ReadAsByteArrayAsync().Result; +#endif + + var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + RequestMessage = request, + Content = new ByteArrayContent([]), + }; + + response.Headers.Add("grpc-status", "0"); + + return response; + } + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs index 3bab01deeda..a2dd781cbb9 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs @@ -4,6 +4,7 @@ #if NETFRAMEWORK using System.Net.Http; #endif +using System.IO.Compression; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; @@ -38,4 +39,77 @@ public void ValidateOtlpHttpExportClientEndpoint(string? optionEndpoint, string? Environment.SetEnvironmentVariable(OtlpSpecConfigDefinitions.DefaultEndpointEnvVarName, null); } } + + [Fact] + public void SendExportRequest_HttpExport_NoCompression_ContentMatchesOriginalBuffer() + { + var payload = "hello world"u8.ToArray(); + + using var testHandler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpHttpExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4318"), + Protocol = OtlpExportProtocol.HttpProtobuf, + Compression = OtlpExportCompression.None, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(payload, payload.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.Equal(payload, testHandler.HttpRequestContent); + + var request = testHandler.HttpRequestMessage; + Assert.NotNull(request?.Content); + Assert.DoesNotContain(request.Content.Headers, h => h.Key == "Content-Encoding"); + } + + [Fact] + public void SendExportRequest_WithGzipCompression_IsCompressed() + { + var payload = "00000000000000000000000000000000"u8.ToArray(); + + using var testHandler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpHttpExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4318"), + Protocol = OtlpExportProtocol.HttpProtobuf, + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(payload, payload.Length, DateTime.UtcNow.AddSeconds(10)); + + var content = testHandler.HttpRequestContent; + + Assert.NotNull(content); + Assert.NotEmpty(content); + Assert.True(content.Length < payload.Length, "The payload was not compressed."); + + byte[] decompressed; + + using (var input = new MemoryStream(content)) + using (var gzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + gzip.CopyTo(output); + decompressed = output.ToArray(); + } + + Assert.NotEmpty(decompressed); + Assert.Equal(payload, decompressed); + + var request = testHandler.HttpRequestMessage; + + Assert.NotNull(request); + Assert.NotNull(request.Content); + Assert.Contains(request.Content.Headers, h => h.Key == "Content-Encoding" && h.Value.Contains("gzip")); + } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs index d64de24edbb..fb0ec22887b 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs @@ -20,7 +20,9 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName, "1001", OtlpSpecConfigDefinitions.DefaultProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.DefaultCompressionEnvVarName, + "gzip"); internal static TestData LoggingData { get; } = new TestData( OtlpExporterOptionsConfigurationType.Logs, @@ -32,7 +34,9 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName, "1002", OtlpSpecConfigDefinitions.LogsProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.LogsCompressionEnvVarName, + "gzip"); internal static MetricsTestData MetricsData { get; } = new MetricsTestData( OtlpSpecConfigDefinitions.MetricsEndpointEnvVarName, @@ -44,6 +48,8 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable "1003", OtlpSpecConfigDefinitions.MetricsProtocolEnvVarName, "http/protobuf", + OtlpSpecConfigDefinitions.MetricsCompressionEnvVarName, + "gzip", OtlpSpecConfigDefinitions.MetricsTemporalityPreferenceEnvVarName, "Delta", OtlpSpecConfigDefinitions.MetricsDefaultHistogramAggregationEnvVarName, @@ -59,32 +65,38 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName, "1004", OtlpSpecConfigDefinitions.TracesProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.TracesCompressionEnvVarName, + "gzip"); [Fact] public void VerifyKeyNamesMatchSpec() { + Assert.Equal("OTEL_EXPORTER_OTLP_COMPRESSION", DefaultData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_ENDPOINT", DefaultData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_HEADERS", DefaultData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_TIMEOUT", DefaultData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_PROTOCOL", DefaultData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TIMEOUT", DefaultData.TimeoutKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_COMPRESSION", LoggingData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", LoggingData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_HEADERS", LoggingData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", LoggingData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", LoggingData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", LoggingData.TimeoutKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_COMPRESSION", MetricsData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", MetricsData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_HEADERS", MetricsData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", MetricsData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", MetricsData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", MetricsData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", MetricsData.TemporalityKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION", MetricsData.HistogramAggregationKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", TracingData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", TracingData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_HEADERS", TracingData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", TracingData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", TracingData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", TracingData.TimeoutKeyName); } public IEnumerator GetEnumerator() @@ -152,7 +164,9 @@ public TestData( string timeoutKeyName, string timeoutValue, string protocolKeyName, - string protocolValue) + string protocolValue, + string compressionKeyName, + string compressionValue) { this.ConfigurationType = configurationType; this.EndpointKeyName = endpointKeyName; @@ -164,6 +178,8 @@ public TestData( this.TimeoutValue = timeoutValue; this.ProtocolKeyName = protocolKeyName; this.ProtocolValue = protocolValue; + this.CompressionKeyName = compressionKeyName; + this.CompressionValue = compressionValue; } public OtlpExporterOptionsConfigurationType ConfigurationType { get; } @@ -186,6 +202,10 @@ public TestData( public string ProtocolValue { get; } + public string CompressionKeyName { get; } + + public string CompressionValue { get; } + public IConfiguration ToConfiguration() => this.AddToConfiguration(new ConfigurationBuilder()).Build(); @@ -193,10 +213,11 @@ public ConfigurationBuilder AddToConfiguration(ConfigurationBuilder configuratio { Dictionary dictionary = new() { + [this.CompressionKeyName] = this.CompressionValue, [this.EndpointKeyName] = this.EndpointValue, [this.HeadersKeyName] = this.HeadersValue, - [this.TimeoutKeyName] = this.TimeoutValue, [this.ProtocolKeyName] = this.ProtocolValue, + [this.TimeoutKeyName] = this.TimeoutValue, }; this.OnAddToDictionary(dictionary); @@ -208,20 +229,22 @@ public ConfigurationBuilder AddToConfiguration(ConfigurationBuilder configuratio public void SetEnvVars() { + Environment.SetEnvironmentVariable(this.CompressionKeyName, this.CompressionValue); Environment.SetEnvironmentVariable(this.EndpointKeyName, this.EndpointValue); Environment.SetEnvironmentVariable(this.HeadersKeyName, this.HeadersValue); - Environment.SetEnvironmentVariable(this.TimeoutKeyName, this.TimeoutValue); Environment.SetEnvironmentVariable(this.ProtocolKeyName, this.ProtocolValue); + Environment.SetEnvironmentVariable(this.TimeoutKeyName, this.TimeoutValue); this.OnSetEnvVars(); } public void ClearEnvVars() { + Environment.SetEnvironmentVariable(this.CompressionKeyName, null); Environment.SetEnvironmentVariable(this.EndpointKeyName, null); Environment.SetEnvironmentVariable(this.HeadersKeyName, null); - Environment.SetEnvironmentVariable(this.TimeoutKeyName, null); Environment.SetEnvironmentVariable(this.ProtocolKeyName, null); + Environment.SetEnvironmentVariable(this.TimeoutKeyName, null); this.OnClearEnvVars(); } @@ -239,6 +262,13 @@ public void AssertMatches(IOtlpExporterOptions otlpExporterOptions) Assert.Equal(protocol, otlpExporterOptions.Protocol); + if (!OtlpExporterOptions.TryParseCompression(this.CompressionValue, out var compression)) + { + Assert.Fail(); + } + + Assert.Equal(compression, otlpExporterOptions.Compression); + var concreteOptions = otlpExporterOptions as OtlpExporterOptions; Assert.NotNull(concreteOptions); Assert.Equal(this.AppendSignalPathToEndpoint, concreteOptions.AppendSignalPathToEndpoint); @@ -269,6 +299,8 @@ public MetricsTestData( string timeoutValue, string protocolKeyName, string protocolValue, + string compressionKeyName, + string compressionValue, string temporalityKeyName, string temporalityValue, string? histogramAggregationKeyName = null, @@ -283,7 +315,9 @@ public MetricsTestData( timeoutKeyName, timeoutValue, protocolKeyName, - protocolValue) + protocolValue, + compressionKeyName, + compressionValue) { this.TemporalityKeyName = temporalityKeyName; this.TemporalityValue = temporalityValue; diff --git a/test/OpenTelemetry.Tests/SuppressInstrumentationTests.cs b/test/OpenTelemetry.Tests/SuppressInstrumentationTests.cs index 730c1b02af5..a55889daefe 100644 --- a/test/OpenTelemetry.Tests/SuppressInstrumentationTests.cs +++ b/test/OpenTelemetry.Tests/SuppressInstrumentationTests.cs @@ -103,4 +103,74 @@ public void IncrementIfTriggeredOnlyWorksInReferenceCountingMode() Assert.Equal(0, SuppressInstrumentationScope.DecrementIfTriggered()); Assert.False(Sdk.SuppressInstrumentation); // Instrumentation is not suppressed anymore } + + [Fact] + public void BeginReturnsNoOpWhenAlreadyAlwaysSuppressed() + { + Assert.False(Sdk.SuppressInstrumentation); + + using var outer = SuppressInstrumentationScope.Begin(value: true); + Assert.True(Sdk.SuppressInstrumentation); + + // Nested Begin(true) while already in always-suppress mode should return a no-op + // disposable and must not alter the suppression state in any way. + using var inner = SuppressInstrumentationScope.Begin(value: true); + Assert.True(Sdk.SuppressInstrumentation); + + inner.Dispose(); + + // Suppression should still be active after the no-op inner scope is disposed. + Assert.True(Sdk.SuppressInstrumentation); + + outer.Dispose(); + Assert.False(Sdk.SuppressInstrumentation); + } + + [Fact] + public void BeginDisposeIsIdempotent() + { + Assert.False(Sdk.SuppressInstrumentation); + + var first = SuppressInstrumentationScope.Begin(); + Assert.True(Sdk.SuppressInstrumentation); + + first.Dispose(); + Assert.False(Sdk.SuppressInstrumentation); + + // Second Dispose() call must be a safe no-op: it must not touch the slot + // even if the underlying scope instance has since been re-rented from the pool. + var second = SuppressInstrumentationScope.Begin(); + Assert.True(Sdk.SuppressInstrumentation); + + // Stale reference - must not affect second + first.Dispose(); + Assert.True(Sdk.SuppressInstrumentation); + + second.Dispose(); + Assert.False(Sdk.SuppressInstrumentation); + } + + [Fact] + public void BeginScopesCanBeNestedAndUnwoundCorrectly() + { + Assert.False(Sdk.SuppressInstrumentation); + + using var first = SuppressInstrumentationScope.Begin(); + Assert.True(Sdk.SuppressInstrumentation); + + using var second = SuppressInstrumentationScope.Begin(value: false); + Assert.False(Sdk.SuppressInstrumentation); // second opted out + + using var third = SuppressInstrumentationScope.Begin(); + Assert.True(Sdk.SuppressInstrumentation); + + third.Dispose(); + Assert.False(Sdk.SuppressInstrumentation); // Back to second + + second.Dispose(); + Assert.True(Sdk.SuppressInstrumentation); // Back to first + + first.Dispose(); + Assert.False(Sdk.SuppressInstrumentation); + } } diff --git a/test/OpenTelemetry.Tests/Trace/Propagation/TraceContextPropagatorTests.cs b/test/OpenTelemetry.Tests/Trace/Propagation/TraceContextPropagatorTests.cs index bedc451b06e..cd21c21e08e 100644 --- a/test/OpenTelemetry.Tests/Trace/Propagation/TraceContextPropagatorTests.cs +++ b/test/OpenTelemetry.Tests/Trace/Propagation/TraceContextPropagatorTests.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Collections; using System.Diagnostics; using Xunit; @@ -14,25 +15,11 @@ public class TraceContextPropagatorTests private const string SpanId = "b9c7c989f97918e1"; private static readonly string[] Empty = []; - private static readonly Func, string, IEnumerable> Getter = (headers, name) => - { - if (headers.TryGetValue(name, out var value)) - { - return [value]; - } + private static readonly Func, string, IEnumerable> Getter = + static (headers, name) => headers.TryGetValue(name, out var value) ? [value] : (IEnumerable)Empty; - return Empty; - }; - - private static readonly Func, string, IEnumerable> ArrayGetter = (headers, name) => - { - if (headers.TryGetValue(name, out var value)) - { - return value; - } - - return []; - }; + private static readonly Func, string, IEnumerable> ArrayGetter = + static (headers, name) => headers.TryGetValue(name, out var value) ? value : (IEnumerable)[]; private static readonly Action, string, string> Setter = (carrier, name, value) => { @@ -56,7 +43,7 @@ public void CanParseExampleFromSpec() Assert.True(ctx.ActivityContext.IsRemote); Assert.True(ctx.ActivityContext.IsValid()); - Assert.True((ctx.ActivityContext.TraceFlags & ActivityTraceFlags.Recorded) != 0); + Assert.NotEqual(0, (int)(ctx.ActivityContext.TraceFlags & ActivityTraceFlags.Recorded)); Assert.Equal($"congo=lZWRzIHRoNhcm5hbCBwbGVhc3VyZS4,rojo=00-{TraceId}-00f067aa0ba902b7-01", ctx.ActivityContext.TraceState); } @@ -74,7 +61,7 @@ public void NotSampled() Assert.Equal(ActivityTraceId.CreateFromString(TraceId.AsSpan()), ctx.ActivityContext.TraceId); Assert.Equal(ActivitySpanId.CreateFromString(SpanId.AsSpan()), ctx.ActivityContext.SpanId); - Assert.True((ctx.ActivityContext.TraceFlags & ActivityTraceFlags.Recorded) == 0); + Assert.Equal(0, (int)(ctx.ActivityContext.TraceFlags & ActivityTraceFlags.Recorded)); Assert.True(ctx.ActivityContext.IsRemote); Assert.True(ctx.ActivityContext.IsValid()); @@ -138,6 +125,147 @@ public void TracestateToString() Assert.Equal("k1=v1,k2=v2,k3=v3", ctx.ActivityContext.TraceState); } + [Fact] + public void Extract_SupportsReadOnlyListCarrierValues() + { + var headers = new Dictionary + { + [TraceParent] = new([$"00-{TraceId}-{SpanId}-01"]), + [TraceState] = new(["k1=v1"]), + }; + + var target = new TraceContextPropagator(); + var actual = target.Extract(default, headers, static (carrier, name) => + carrier.TryGetValue(name, out var value) ? value : new ReadOnlyCarrierValues([])); + + Assert.Equal(ActivityTraceId.CreateFromString(TraceId.AsSpan()), actual.ActivityContext.TraceId); + Assert.Equal(ActivitySpanId.CreateFromString(SpanId.AsSpan()), actual.ActivityContext.SpanId); + Assert.Equal("k1=v1", actual.ActivityContext.TraceState); + } + + [Fact] + public void Extract_SupportsEnumerableCarrierValues() + { + var headers = new Dictionary + { + [TraceParent] = new([$"00-{TraceId}-{SpanId}-01"]), + [TraceState] = new([" k1=v1 , k2=v2 "]), + }; + + var target = new TraceContextPropagator(); + var actual = target.Extract(default, headers, static (carrier, name) => + carrier.TryGetValue(name, out var value) ? value : new EnumerableCarrierValues([])); + + Assert.Equal(ActivityTraceId.CreateFromString(TraceId.AsSpan()), actual.ActivityContext.TraceId); + Assert.Equal(ActivitySpanId.CreateFromString(SpanId.AsSpan()), actual.ActivityContext.SpanId); + Assert.Equal("k1=v1,k2=v2", actual.ActivityContext.TraceState); + } + + [Fact] + public void Extract_EnumeratesEnumerableTracestateValuesOnce() + { + var tracestateValues = new SingleUseEnumerableCarrierValues(" k1=v1 , k2=v2 "); + var headers = new Dictionary> + { + [TraceParent] = new EnumerableCarrierValues($"00-{TraceId}-{SpanId}-01"), + [TraceState] = tracestateValues, + }; + + var target = new TraceContextPropagator(); + var actual = target.Extract(default, headers, static (carrier, name) => + carrier.TryGetValue(name, out var value) ? value : Empty); + + Assert.Equal(ActivityTraceId.CreateFromString(TraceId.AsSpan()), actual.ActivityContext.TraceId); + Assert.Equal(ActivitySpanId.CreateFromString(SpanId.AsSpan()), actual.ActivityContext.SpanId); + Assert.Equal("k1=v1,k2=v2", actual.ActivityContext.TraceState); + Assert.Equal(1, tracestateValues.EnumerationCount); + } + + [Fact] + public void Extract_IgnoresMultipleEnumerableTraceparentValues() + { + var headers = new Dictionary + { + [TraceParent] = new([$"00-{TraceId}-{SpanId}-01", $"00-{TraceId}-{SpanId}-00"]), + }; + + var target = new TraceContextPropagator(); + var context = target.Extract(default, headers, static (carrier, name) => + carrier.TryGetValue(name, out var value) ? value : new EnumerableCarrierValues([])); + + Assert.False(context.ActivityContext.IsValid()); + } + + [Fact] + public void Extract_IgnoresEmptyEnumerableTracestateValues() + { + var headers = new Dictionary + { + [TraceParent] = new([$"00-{TraceId}-{SpanId}-01"]), + [TraceState] = new([]), + }; + + var target = new TraceContextPropagator(); + var context = target.Extract(default, headers, static (carrier, name) => + carrier.TryGetValue(name, out var value) ? value : new EnumerableCarrierValues([])); + + Assert.Equal(ActivityTraceId.CreateFromString(TraceId.AsSpan()), context.ActivityContext.TraceId); + Assert.Null(context.ActivityContext.TraceState); + } + + [Fact] + public void TryExtractTracestate_SingleHeaderReturnsOriginalString() + { + Assert.True(TraceContextPropagator.TryExtractTracestate(["k1=v1,k2=v2"], out var actual)); + Assert.Equal("k1=v1,k2=v2", actual); + } + + [Fact] + public void TryExtractTracestate_SingleHeaderReturnsEmptyForWhitespaceOnly() + { + Assert.True(TraceContextPropagator.TryExtractTracestate([" , "], out var actual)); + Assert.Empty(actual); + } + + [Fact] + public void TryExtractTracestate_SingleHeaderRejectsTooManyMembers() + { + var tracestate = string.Join(",", Enumerable.Range(1, 33).Select(static i => $"k{i:D2}=v{i:D2}")); + + Assert.False(TraceContextPropagator.TryExtractTracestate([tracestate], out _)); + } + + [Fact] + public void TryExtractTracestate_SingleHeaderRejectsDuplicateLongKeys() + { + var key = new string('a', 33); + + Assert.False(TraceContextPropagator.TryExtractTracestate([$"{key}=1,{key}=2"], out _)); + } + + [Fact] + public async Task Extract_DoesNotHangWhenLaterKeyAppearsInsideEarlierValue() + { + // Regression test for GHSA-8785-wc3w-h8q6 + const string tracestate = "foo1=foo2,foo2=1"; + + var deadline = TimeSpan.FromSeconds(1); + + var extractionTask = Task.Run(() => CallTraceContextPropagator(tracestate)); + var completedTask = await Task.WhenAny(extractionTask, Task.Delay(deadline)); + + Assert.True(extractionTask.IsCompleted, $"The task did not complete within {deadline}."); + Assert.Same(extractionTask, completedTask); + Assert.Equal(tracestate, await extractionTask); + } + + [Fact] + public void TryExtractTracestate_NullCollectionReturnsEmpty() + { + Assert.True(TraceContextPropagator.TryExtractTracestate((IEnumerable?)null, out var actual)); + Assert.Empty(actual); + } + [Fact] public void Inject_NoTracestate() { @@ -333,4 +461,54 @@ private static string CallTraceContextPropagator(string[] tracestate) Assert.NotNull(traceState); return traceState; } + + private sealed class ReadOnlyCarrierValues(params string[] values) : IReadOnlyList + { + public int Count => values.Length; + + public string this[int index] => values[index]; + + public IEnumerator GetEnumerator() + { + foreach (var value in values) + { + yield return value; + } + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private sealed class EnumerableCarrierValues(params string[] values) : IEnumerable + { + public IEnumerator GetEnumerator() + { + foreach (var value in values) + { + yield return value; + } + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + + private sealed class SingleUseEnumerableCarrierValues(params string[] values) : IEnumerable + { + public int EnumerationCount { get; private set; } + + public IEnumerator GetEnumerator() + { + if (this.EnumerationCount++ > 0) + { + throw new InvalidOperationException("Sequence was enumerated multiple times."); + } + + foreach (var value in values) + { + yield return value; + } + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } }