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