diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt index e69de29bb2d..8840de75e96 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 03fc536aa2d..bb0b5e16796 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -10,6 +10,11 @@ Notes](../../RELEASENOTES.md). * Fixed `NullReferenceException` when exporting logs if the scope key is null. ([#7186](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7186)) +* 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`. + ([#7055](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7055)) + ## 1.15.3 Released 2026-Apr-21 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 05e2469c9b8..3af8b2f8be1 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs @@ -44,6 +44,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; } @@ -52,6 +53,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; @@ -88,14 +91,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 3c9b3c6c8f9..cb1326bbb39 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) { @@ -37,6 +50,11 @@ public OtlpGrpcExportClient(OtlpExporterOptions options, HttpClient httpClient, // We need the entire response content to ensure that the response trailers are received internal override HttpCompletionOption CompletionOption => HttpCompletionOption.ResponseContentRead; +#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) { @@ -50,6 +68,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(); @@ -173,10 +197,54 @@ 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. +#if NET + var compressedStream = new PooledBufferStream(); +#else + var compressedStream = new MemoryStream(); +#endif + + // 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; + + OpenTelemetryProtocolExporterEventSource.Log.CompressedGrpcPayload("gzip", contentLength, compressedStream.Length); + + 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..1cfef04b053 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,34 @@ 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); + } + +#if NET + var compressedStream = new PooledBufferStream(); +#else + var compressedStream = new MemoryStream(); +#endif + + using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true)) + { + gzipStream.Write(buffer, 0, contentLength); + } + + compressedStream.Position = 0; + + OpenTelemetryProtocolExporterEventSource.Log.CompressedHttpPayload("gzip", contentLength, compressedStream.Length); + + 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/ExportClient/PooledBufferStream.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs new file mode 100644 index 00000000000..ff94d8b4265 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs @@ -0,0 +1,374 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Buffers; +using System.Diagnostics; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; + +/// +/// A growable Stream backed by buffers rented from an ArrayPool{byte}. +/// Returns rented buffers to the pool when disposed (unless detached). +/// +internal sealed class PooledBufferStream : Stream +{ + private readonly ArrayPool pool; + + private byte[] buffer; + private bool disposed; + private int length; + private int position; + + public PooledBufferStream(int initialCapacity = 0, ArrayPool? pool = null) + { + ArgumentOutOfRangeException.ThrowIfNegative(initialCapacity, nameof(initialCapacity)); + + this.pool = pool ?? ArrayPool.Shared; + + // Always rent at least 1 byte so we can hand out a stable array instance. + this.buffer = this.pool.Rent(Math.Max(1, initialCapacity)); + this.length = 0; + this.position = 0; + } + + /// + public override bool CanRead => !this.disposed; + + /// + public override bool CanSeek => !this.disposed; + + /// + public override bool CanWrite => !this.disposed; + + /// + public override long Length + { + get + { + this.ThrowIfDisposed(); + return this.length; + } + } + + /// + public override long Position + { + get + { + this.ThrowIfDisposed(); + return this.position; + } + + set + { + this.ThrowIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfNegative(value, nameof(value)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, int.MaxValue, nameof(value)); + + this.position = (int)value; + } + } + + /// + public override void Flush() + => this.ThrowIfDisposed(); // Nothing to flush + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateReadWriteArgs(buffer, offset, count); + + this.ThrowIfDisposed(); + + int available = this.length - this.position; + if (available <= 0) + { + return 0; + } + + int toCopy = Math.Min(available, count); + Buffer.BlockCopy(this.buffer, this.position, buffer, offset, toCopy); + this.position += toCopy; + + return toCopy; + } + + /// + public override int Read(Span destination) + { + this.ThrowIfDisposed(); + + int available = this.length - this.position; + if (available <= 0) + { + return 0; + } + + int toCopy = Math.Min(available, destination.Length); + this.buffer.AsSpan(this.position, toCopy).CopyTo(destination); + this.position += toCopy; + + return toCopy; + } + + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + try + { + int bytesRead = this.Read(buffer.Span); + return ValueTask.FromResult(bytesRead); + } + catch (Exception ex) + { + return ValueTask.FromException(ex); + } + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + this.ThrowIfDisposed(); + + long newOffset = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => this.position + offset, + SeekOrigin.End => this.length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin), origin, "Invalid seek origin."), + }; + + if (newOffset < 0 || newOffset > int.MaxValue) + { + throw new IOException("Attempted to seek outside the bounds of the stream."); + } + + this.position = (int)newOffset; + return this.position; + } + + /// + public override void SetLength(long value) + { + this.ThrowIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfNegative(value, nameof(value)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, int.MaxValue, nameof(value)); + + int newLength = (int)value; + this.EnsureCapacity(newLength); + + // If we grew length, zero the gap to preserve typical MemoryStream behavior. + if (newLength > this.length) + { + this.buffer.AsSpan(this.length, newLength - this.length).Clear(); + } + + this.length = newLength; + + if (this.position > this.length) + { + this.position = this.length; + } + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + ValidateReadWriteArgs(buffer, offset, count); + + this.ThrowIfDisposed(); + this.EnsureWriteCapacity(count); + + Buffer.BlockCopy(buffer, offset, this.buffer, this.position, count); + this.position += count; + + if (this.position > this.length) + { + this.length = this.position; + } + } + + /// + public override void Write(ReadOnlySpan source) + { + this.ThrowIfDisposed(); + + this.EnsureWriteCapacity(source.Length); + + source.CopyTo(this.buffer.AsSpan(this.position)); + this.position += source.Length; + + if (this.position > this.length) + { + this.length = this.position; + } + } + + /// + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + try + { + this.Write(buffer.Span); + return ValueTask.CompletedTask; + } + catch (Exception ex) + { + return ValueTask.FromException(ex); + } + } + + /// + public override void WriteByte(byte value) + { + this.ThrowIfDisposed(); + + this.EnsureWriteCapacity(1); + + this.buffer[this.position] = value; + this.position++; + + if (this.position > this.length) + { + this.length = this.position; + } + } + + /// + public override int ReadByte() + { + this.ThrowIfDisposed(); + return this.position >= this.length ? -1 : this.buffer[this.position++]; + } + + /// + protected override void Dispose(bool disposing) + { + if (this.disposed) + { + base.Dispose(disposing); + return; + } + + this.disposed = true; + + if (disposing) + { + var rented = this.buffer; + this.buffer = []; + this.length = 0; + this.position = 0; + + if (rented is { Length: > 0 }) + { + this.pool.Return(rented); + } + } + + base.Dispose(disposing); + } + + private static int ComputeNewCapacity(int minCapacity, int currentCapacity) + { + // Growth heuristic: double, with a small starting point. + int newCapacity = currentCapacity switch + { + <= 0 => 256, + < 1024 * 1024 => currentCapacity * 2, + _ => currentCapacity + (currentCapacity / 2), // 1.5x after 1MB + }; + + if (newCapacity < minCapacity) + { + newCapacity = minCapacity; + } + + // Avoid overflow edge-cases. + if (newCapacity < 0) + { + newCapacity = minCapacity; + } + + return newCapacity; + } + + private static void ValidateReadWriteArgs(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)offset, (uint)buffer.Length); + ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)count, (uint)(buffer.Length - offset)); + } + + private void EnsureCapacity(int capacity) + { + this.ThrowIfDisposed(); + + ArgumentOutOfRangeException.ThrowIfNegative(capacity, nameof(capacity)); + + if (this.buffer.Length < capacity) + { + this.Grow(capacity); + } + } + + private void EnsureWriteCapacity(int additionalCount) + { + Debug.Assert(additionalCount >= 0, $"{nameof(additionalCount)} is negative."); + + long required = (long)this.position + additionalCount; + if (required > int.MaxValue) + { + throw new IOException($"The stream's buffer cannot be greater than {int.MaxValue} bytes in length."); + } + + if (required > this.buffer.Length) + { + this.Grow((int)required); + } + + // If writing beyond current length, ensure the gap is zeroed (MemoryStream-like). + // For example: Position=10, Length=0, Write 1 byte => bytes [0..9] become 0. + if (this.position > this.length) + { + this.buffer.AsSpan(this.length, this.position - this.length).Clear(); + } + } + + private void Grow(int minCapacity) + { + this.ThrowIfDisposed(); + + byte[] previous = this.buffer; + + int newCapacity = ComputeNewCapacity(minCapacity, previous.Length); + + byte[] replacement = this.pool.Rent(newCapacity); + + if (this.length != 0) + { + Buffer.BlockCopy(previous, 0, replacement, 0, this.length); + } + + this.buffer = replacement; + this.pool.Return(previous); + } + + private void ThrowIfDisposed() + => ObjectDisposedException.ThrowIf(this.disposed, this); +} + +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index 9439a7344cc..1b66759030e 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -306,6 +306,20 @@ internal void SecureHttpClientCreationFailed(string exception) => this.WriteEvent(36, exception); #endif + [Event( + 37, + Message = "gRPC export payload content was compressed with '{0}' from {1} bytes to {2} bytes.", + Level = EventLevel.Verbose)] + internal void CompressedGrpcPayload(string compression, long uncompressedLength, long compressedLength) => + this.WriteEvent(37, compression, uncompressedLength, compressedLength); + + [Event( + 38, + Message = "HTTP export payload content was compressed with '{0}' from {1} bytes to {2} bytes.", + Level = EventLevel.Verbose)] + internal void CompressedHttpPayload(string compression, long uncompressedLength, long compressedLength) => + this.WriteEvent(38, compression, uncompressedLength, compressedLength); + private static string RedactEndpointUri(Uri endpoint) => endpoint.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped); } 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..04881a05b1d --- /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..470bf90c403 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.Exporter.OpenTelemetryProtocol/README.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md index d67f1b234a2..ed995bdeee2 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md @@ -341,6 +341,9 @@ appBuilder.Services.AddOptions() > provided, including the signal-specific path v1/{signal}. For example, for > traces, the full URL will look like `http://your-custom-endpoint/v1/traces`. +* `Compression`: Optionally compress the export payload. The default is no + compression. The only supported compression option is `OtlpExportCompression.GZip`. + * `Headers`: Optional headers for the connection. * `HttpClientFactory`: A factory function called to create the `HttpClient` @@ -447,12 +450,13 @@ or reader The following environment variables can be used to override the default values of the `OtlpExporterOptions`: - | Environment variable | `OtlpExporterOptions` property | - | ------------------------------| --------------------------------------| - | `OTEL_EXPORTER_OTLP_ENDPOINT` | `Endpoint` | - | `OTEL_EXPORTER_OTLP_HEADERS` | `Headers` | - | `OTEL_EXPORTER_OTLP_TIMEOUT` | `TimeoutMilliseconds` | - | `OTEL_EXPORTER_OTLP_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`)| + | Environment variable | `OtlpExporterOptions` property | + | ---------------------------------| ---------------------------------------| + | `OTEL_EXPORTER_OTLP_COMPRESSION` | `Compression` (`none` or `gzip`) | + | `OTEL_EXPORTER_OTLP_ENDPOINT` | `Endpoint` | + | `OTEL_EXPORTER_OTLP_HEADERS` | `Headers` | + | `OTEL_EXPORTER_OTLP_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`) | + | `OTEL_EXPORTER_OTLP_TIMEOUT` | `TimeoutMilliseconds` | The following environment variables can be used to configure mTLS (mutual TLS) authentication (.NET 8.0+ only): @@ -479,12 +483,13 @@ or reader of the `OtlpExporterOptions` used for logging when using the [UseOtlpExporter extension](#enable-otlp-exporter-for-all-signals): - | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | - | --------------------------------------| --------------------------------------|-----------------|-----------------| - | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `Endpoint` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `Headers` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_LOGS_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`)| Supported | Not supported | + | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | + | --------------------------------------| ---------------------------------------|-----------------|-----------------| + | `OTEL_EXPORTER_OTLP_LOGS_COMPRESSION` | `Compression` (`none` or `gzip`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `Endpoint` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `Headers` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_LOGS_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | * Metrics: @@ -516,12 +521,13 @@ or reader of the `OtlpExporterOptions` used for metrics when using the [UseOtlpExporter extension](#enable-otlp-exporter-for-all-signals): - | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | - | --------------------------------------| --------------------------------------|-----------------|-----------------| - | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `Endpoint` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `Headers` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_METRICS_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`)| Supported | Not supported | + | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | + | -----------------------------------------| ---------------------------------------|-----------------|-----------------| + | `OTEL_EXPORTER_OTLP_METRICS_COMPRESSION` | `Compression` (`none` or `gzip`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `Endpoint` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `Headers` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_METRICS_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | * Tracing: @@ -539,12 +545,13 @@ or reader of the `OtlpExporterOptions` used for tracing when using the [UseOtlpExporter extension](#enable-otlp-exporter-for-all-signals): - | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | - | --------------------------------------| --------------------------------------|-----------------|-----------------| - | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `Endpoint` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `Headers` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | - | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`)| Supported | Not supported | + | Environment variable | `OtlpExporterOptions` property | UseOtlpExporter | AddOtlpExporter | + | ----------------------------------------| ---------------------------------------|-----------------|-----------------| + | `OTEL_EXPORTER_OTLP_TRACES_COMPRESSION` | `Compression` (`none` or `gzip`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `Endpoint` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `Headers` | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`) | Supported | Not supported | + | `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` | `TimeoutMilliseconds` | Supported | Not supported | ### Attribute limits diff --git a/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs new file mode 100644 index 00000000000..9a2cea4304c --- /dev/null +++ b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs @@ -0,0 +1,110 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +extern alias OpenTelemetryProtocol; + +using System.Diagnostics; +using System.Net; +#if NETFRAMEWORK +using System.Net.Http; +using System.Net.Http.Headers; +#endif +using BenchmarkDotNet.Attributes; +using Benchmarks.Helper; +using OpenTelemetry; +using OpenTelemetry.Internal; +using OpenTelemetryProtocol::OpenTelemetry.Exporter; +using OpenTelemetryProtocol::OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using OpenTelemetryProtocol::OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; +using OpenTelemetryProtocol::OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Transmission; + +namespace Benchmarks.Exporter; + +#pragma warning disable CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +public class OtlpExporterCompressionBenchmarks +#pragma warning restore CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +{ + private const int NumberOfBatches = 2; + private const int NumberOfSpans = 10_000; + + private OtlpTraceExporter? exporter; + private Activity? activity; + private CircularBuffer? activityBatch; + + [Params(OtlpExportCompression.None, OtlpExportCompression.GZip)] + public OtlpExportCompression Compression { get; set; } + + [GlobalSetup] + public void GlobalSetup() + { + var options = new OtlpExporterOptions() + { + Compression = this.Compression, + Protocol = OtlpExportProtocol.HttpProtobuf, + HttpClientFactory = () => new HttpClient(new StubHttpClientHandler(), true), + }; + + this.exporter = new OtlpTraceExporter( + options, + new SdkLimitOptions(), + new ExperimentalOptions(), +#pragma warning disable CA2000 // Dispose objects before losing scope + new OtlpExporterTransmissionHandler(new OtlpGrpcExportClient(options, options.HttpClientFactory(), "opentelemetry.proto.collector.trace.v1.TraceService/Export"), options.TimeoutMilliseconds)); +#pragma warning restore CA2000 // Dispose objects before losing scope + + this.activity = ActivityHelper.CreateTestActivity(); + this.activityBatch = new CircularBuffer(NumberOfSpans); + } + + [GlobalCleanup] + public void GlobalCleanup() + { + this.activity?.Dispose(); + this.exporter?.Shutdown(); + this.exporter?.Dispose(); + } + + [Benchmark] + public void OtlpExporter_Compression() + { + for (var i = 0; i < NumberOfBatches; i++) + { + for (var j = 0; j < NumberOfSpans; j++) + { + this.activityBatch!.Add(this.activity!); + } + + this.exporter!.Export(new Batch(this.activityBatch!, NumberOfSpans)); + } + } + + private sealed class StubHttpClientHandler : DelegatingHandler + { +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => CreateResponse(); +#endif + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(CreateResponse()); + + private static HttpResponseMessage CreateResponse() + { + var response = new HttpResponseMessage(HttpStatusCode.OK); + +#if NET + response.TrailingHeaders.Add("grpc-status", "0"); +#else + response.RequestMessage.Properties.Add("__ResponseTrailers", new ResponseTrailers() + { + { "grpc-status", "0" }, + }); +#endif + + return response; + } + +#if NETFRAMEWORK + private sealed class ResponseTrailers : HttpHeaders; +#endif + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/PooledBufferStreamTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/PooledBufferStreamTests.cs new file mode 100644 index 00000000000..5ac966e58bd --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/PooledBufferStreamTests.cs @@ -0,0 +1,187 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Buffers; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests.Implementation.ExportClient; + +public class PooledBufferStreamTests +{ + [Fact] + public void ReadWriteSeekAndFlush_BehaveLikeMemoryStream() + { + using var stream = new PooledBufferStream(initialCapacity: 1); + + stream.WriteByte(1); + stream.Write([2, 3, 4]); + stream.Flush(); + + Assert.Equal(4, stream.Length); + Assert.Equal(4, stream.Position); + Assert.Equal(2, stream.Seek(-2, SeekOrigin.Current)); + Assert.Equal(4, stream.Seek(0, SeekOrigin.End)); + + stream.Position = 0; + + Span firstRead = stackalloc byte[2]; + Assert.Equal(2, stream.Read(firstRead)); + Assert.Equal(new byte[] { 1, 2 }, firstRead.ToArray()); + + var remainder = new byte[2]; + Assert.Equal(2, stream.Read(remainder, 0, remainder.Length)); + Assert.Equal(new byte[] { 3, 4 }, remainder); + Assert.Equal(-1, stream.ReadByte()); + Assert.Equal(0, stream.Read(remainder, 0, remainder.Length)); + Assert.Equal(0, stream.Read([])); + } + + [Fact] + public void SparseWritesAndSetLength_ZeroIntermediateBytes_AndClampPosition() + { + using var stream = new PooledBufferStream(initialCapacity: 1); + + stream.Write([1, 2]); + stream.Position = 5; + stream.WriteByte(9); + + stream.SetLength(8); + + stream.Position = 0; + + var expandedBuffer = new byte[8]; + Assert.Equal(8, stream.Read(expandedBuffer, 0, expandedBuffer.Length)); + Assert.Equal(new byte[] { 1, 2, 0, 0, 0, 9, 0, 0 }, expandedBuffer); + + stream.Position = 8; + stream.SetLength(3); + + Assert.Equal(3, stream.Length); + Assert.Equal(3, stream.Position); + + stream.Position = 0; + + var buffer = new byte[3]; + Assert.Equal(3, stream.Read(buffer, 0, buffer.Length)); + Assert.Equal(new byte[] { 1, 2, 0 }, buffer); + } + + [Fact] + public async Task AsyncReadWrite_HonorCancellationAndDisposedState() + { + var stream = new PooledBufferStream(); + + await stream.WriteAsync(new byte[] { 10, 20, 30 }); + stream.Position = 0; + + var readBuffer = new byte[3]; + Assert.Equal(3, await stream.ReadAsync(readBuffer)); + Assert.Equal(new byte[] { 10, 20, 30 }, readBuffer); + + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => stream.ReadAsync(new byte[1], cancellationTokenSource.Token).AsTask()); + await Assert.ThrowsAnyAsync(() => stream.WriteAsync(new byte[1], cancellationTokenSource.Token).AsTask()); + + await stream.DisposeAsync(); + + await Assert.ThrowsAsync(() => stream.ReadAsync(new byte[1]).AsTask()); + await Assert.ThrowsAsync(() => stream.WriteAsync(new byte[1]).AsTask()); + } + + [Fact] + public void SeekAndPositionValidation_ThrowsForInvalidValues() + { + Assert.Throws(() => new PooledBufferStream(-1)); + + using var stream = new PooledBufferStream(); + + Assert.Throws(() => stream.Position = -1); + Assert.Throws(() => stream.Position = (long)int.MaxValue + 1); + Assert.Throws(() => stream.SetLength(-1)); + Assert.Throws(() => stream.SetLength((long)int.MaxValue + 1)); + Assert.Throws(() => stream.Seek(0, (SeekOrigin)99)); + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin)); + Assert.Throws(() => stream.Seek((long)int.MaxValue + 1, SeekOrigin.Begin)); + } + + [Fact] + public void ReadAndWriteValidation_ThrowsForInvalidArguments() + { + using var stream = new PooledBufferStream(); + + Assert.Throws(() => stream.Read(null!, 0, 0)); + Assert.Throws(() => stream.Write(null!, 0, 0)); + Assert.Throws(() => stream.Read([1], 2, 0)); + Assert.Throws(() => stream.Write([1], 2, 0)); + Assert.Throws(() => stream.Read([1], 0, 2)); + Assert.Throws(() => stream.Write([1], 0, 2)); + } + + [Fact] + public void WriteByte_WhenPositionWouldOverflow_Throws() + { + using var stream = new PooledBufferStream(); + + stream.Position = int.MaxValue; + + var exception = Assert.Throws(() => stream.WriteByte(1)); + Assert.Contains(int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture), exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Dispose_ReturnsRentedBuffersAndMarksStreamUnavailable() + { + var pool = new TrackingArrayPool(); + var stream = new PooledBufferStream(initialCapacity: 1, pool); + + stream.Write([1, 2]); + + Assert.Equal(2, pool.Rented.Count); + Assert.Single(pool.Returned); + + stream.Dispose(); + + Assert.False(stream.CanRead); + Assert.False(stream.CanSeek); + Assert.False(stream.CanWrite); + Assert.Equal(2, pool.Returned.Count); + Assert.Same(pool.Rented[0], pool.Returned[0]); + Assert.Same(pool.Rented[1], pool.Returned[1]); + Assert.Throws(stream.Flush); + Assert.Throws(() => _ = stream.Length); + + stream.Dispose(); + + Assert.Equal(2, pool.Returned.Count); + } + + private sealed class TrackingArrayPool : ArrayPool + { + public List Rented { get; } = []; + + public List Returned { get; } = []; + + public override byte[] Rent(int minimumLength) + { + var buffer = new byte[minimumLength]; + this.Rented.Add(buffer); + return buffer; + } + + public override void Return(byte[] array, bool clearArray = false) + { + if (clearArray) + { + Array.Clear(array); + } + + this.Returned.Add(array); + } + } +} + +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index fd56c65bb8a..762aab51d45 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..ba348b7336b --- /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..450f13f032d 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;