From f617492bbeb7fe2abb0e8811178f9103804d0c38 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 10 Apr 2026 14:06:36 +0100 Subject: [PATCH 01/18] [OTLP] Add support for GZip compression Add support for GZip compression to the OTLP exporter. Picks up from #6494. Resolves #3961. Co-Authored-By: Hannah Haering <157852144+hannahhaering@users.noreply.github.com> --- .../.publicApi/Stable/PublicAPI.Unshipped.txt | 6 + .../CHANGELOG.md | 5 + .../IOtlpExporterOptions.cs | 5 + .../ExportClient/OtlpExportClient.cs | 22 ++- .../ExportClient/OtlpGrpcExportClient.cs | 51 ++++++ .../ExportClient/OtlpHttpExportClient.cs | 24 +++ .../OtlpSpecConfigDefinitions.cs | 4 + .../OtlpExportCompression.cs | 23 +++ .../OtlpExporterOptions.cs | 56 +++++- .../OtlpExporterOptionsTests.cs | 20 +- .../OtlpGrpcExportClientTests.cs | 171 ++++++++++++++++++ .../OtlpHttpExportClientTests.cs | 74 ++++++++ .../OtlpSpecConfigDefinitionTests.cs | 58 ++++-- 13 files changed, 489 insertions(+), 30 deletions(-) create mode 100644 src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs create mode 100644 test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs 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..368352022ff 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -4,7 +4,9 @@ #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 OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient.Grpc; @@ -14,6 +16,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"); @@ -47,6 +55,11 @@ 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.Add("grpc-encoding", "gzip"); + } + httpResponse = this.SendHttpRequest(httpRequest, cancellationToken); httpResponse.EnsureSuccessStatusCode(); @@ -170,6 +183,44 @@ 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 5-byte gRPC frame header. + compressedStream.Write([0, 0, 0, 0, 0], 0, GrpcMessageHeaderSize); + + 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 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..aa7d9d9e5bc 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,16 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( { this.TimeoutMilliseconds = timeout; } + + if (compressionEnvVarKey != null + && configuration.TryGetValue( + OpenTelemetryProtocolExporterEventSource.Log, + compressionEnvVarKey, + TryParseCompression, + out var compression)) + { + this.Compression = compression; + } } internal OtlpExporterOptions ApplyDefaults(OtlpExporterOptions defaultExporterOptions) @@ -247,6 +285,8 @@ internal OtlpExporterOptions ApplyDefaults(OtlpExporterOptions defaultExporterOp this.httpClientFactory ??= defaultExporterOptions.httpClientFactory; + this.compression ??= defaultExporterOptions.compression; + return this; } @@ -266,7 +306,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: true, OtlpSpecConfigDefinitions.DefaultProtocolEnvVarName, OtlpSpecConfigDefinitions.DefaultHeadersEnvVarName, - OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName); + OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName, + OtlpSpecConfigDefinitions.DefaultCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Logs) { @@ -276,7 +317,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.LogsProtocolEnvVarName, OtlpSpecConfigDefinitions.LogsHeadersEnvVarName, - OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName); + OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName, + OtlpSpecConfigDefinitions.LogsCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Metrics) { @@ -286,7 +328,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.MetricsProtocolEnvVarName, OtlpSpecConfigDefinitions.MetricsHeadersEnvVarName, - OtlpSpecConfigDefinitions.MetricsTimeoutEnvVarName); + OtlpSpecConfigDefinitions.MetricsTimeoutEnvVarName, + OtlpSpecConfigDefinitions.MetricsCompressionEnvVarName); } else if (configurationType == OtlpExporterOptionsConfigurationType.Traces) { @@ -296,7 +339,8 @@ private void ApplyConfiguration( appendSignalPathToEndpoint: false, OtlpSpecConfigDefinitions.TracesProtocolEnvVarName, OtlpSpecConfigDefinitions.TracesHeadersEnvVarName, - OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName); + OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName, + OtlpSpecConfigDefinitions.TracesCompressionEnvVarName); } else { diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index fd56c65bb8a..80418d24f81 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,7 @@ 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); } [Fact] diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs new file mode 100644 index 00000000000..6b0118b0190 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs @@ -0,0 +1,171 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET +using System.Net.Http; +#endif +using System.Buffers.Binary; +using System.IO.Compression; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests.Implementation.ExportClient; + +public class OtlpGrpcExportClientTests +{ + private const int GrpcHeaderSize = 5; + + [Fact] + public void SendExportRequest_GrpcExport_NoCompression_ContentMatchesOriginalPayload() + { + var protobufPayload = "grpc test payload"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.None, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + Assert.True(content.Length >= GrpcHeaderSize, "No content was written."); + + Assert.Equal(0, content[0]); + + var declaredLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + Assert.Equal(protobufPayload.Length, declaredLength); + Assert.Equal(protobufPayload, content.AsSpan(GrpcHeaderSize, protobufPayload.Length).ToArray()); + + Assert.NotNull(testHandler.CapturedRequestHeaders); + Assert.DoesNotContain(testHandler.CapturedRequestHeaders, h => h.Key == "grpc-encoding"); + } + + [Fact] + public void SendExportRequest_GrpcExport_GzipCompression_FrameLengthMatchesCompressedPayload() + { + var protobufPayload = "grpc test payload for compression"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + Assert.True(content.Length >= GrpcHeaderSize, "No content was written."); + + Assert.Equal(1, testHandler.CapturedRequestBytes[0]); + + var compressedLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + Assert.Equal(content.Length - GrpcHeaderSize, compressedLength); + } + + [Fact] + public void SendExportRequest_GrpcExport_GzipCompression_PayloadDecompressesToOriginalProtobuf() + { + var protobufPayload = "grpc test payload for compression"u8.ToArray(); + var buffer = BuildGrpcFrame(protobufPayload); + + using var testHandler = new TestGrpcMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpGrpcExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4317"), + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(buffer, buffer.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.NotNull(testHandler.CapturedRequestBytes); + var content = testHandler.CapturedRequestBytes; + var compressedLength = (int)BinaryPrimitives.ReadUInt32BigEndian(content.AsSpan(1, 4)); + var decompressed = Decompress(content.AsSpan(GrpcHeaderSize, compressedLength).ToArray()); + Assert.Equal(protobufPayload, decompressed); + + Assert.NotNull(testHandler.CapturedRequestHeaders); + Assert.Contains( + testHandler.CapturedRequestHeaders, + h => h.Key == "grpc-encoding" && h.Value.Contains("gzip")); + } + + private static byte[] BuildGrpcFrame(byte[] protobufPayload) + { + var frame = new byte[GrpcHeaderSize + protobufPayload.Length]; + frame[0] = 0; + + BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(1, 4), (uint)protobufPayload.Length); + + protobufPayload.CopyTo(frame, GrpcHeaderSize); + + return frame; + } + + private static byte[] Decompress(byte[] compressed) + { + using var input = new MemoryStream(compressed); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + + gzip.CopyTo(output); + + return output.ToArray(); + } + + private sealed class TestGrpcMessageHandler : HttpMessageHandler + { + public byte[]? CapturedRequestBytes { get; private set; } + + public List>>? CapturedRequestHeaders { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(this.Handle(request, cancellationToken)); + +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + => this.Handle(request, cancellationToken); +#endif + + private HttpResponseMessage Handle(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.CapturedRequestHeaders = [.. request.Headers]; + +#if NET + this.CapturedRequestBytes = request.Content!.ReadAsByteArrayAsync(cancellationToken).Result; +#else + this.CapturedRequestBytes = request.Content.ReadAsByteArrayAsync().Result; +#endif + + var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + RequestMessage = request, + Content = new ByteArrayContent([]), + }; + + response.Headers.Add("grpc-status", "0"); + + return response; + } + } +} diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs index 3bab01deeda..a2dd781cbb9 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs @@ -4,6 +4,7 @@ #if NETFRAMEWORK using System.Net.Http; #endif +using System.IO.Compression; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; @@ -38,4 +39,77 @@ public void ValidateOtlpHttpExportClientEndpoint(string? optionEndpoint, string? Environment.SetEnvironmentVariable(OtlpSpecConfigDefinitions.DefaultEndpointEnvVarName, null); } } + + [Fact] + public void SendExportRequest_HttpExport_NoCompression_ContentMatchesOriginalBuffer() + { + var payload = "hello world"u8.ToArray(); + + using var testHandler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpHttpExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4318"), + Protocol = OtlpExportProtocol.HttpProtobuf, + Compression = OtlpExportCompression.None, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(payload, payload.Length, DateTime.UtcNow.AddSeconds(10)); + + Assert.Equal(payload, testHandler.HttpRequestContent); + + var request = testHandler.HttpRequestMessage; + Assert.NotNull(request?.Content); + Assert.DoesNotContain(request.Content.Headers, h => h.Key == "Content-Encoding"); + } + + [Fact] + public void SendExportRequest_WithGzipCompression_IsCompressed() + { + var payload = "00000000000000000000000000000000"u8.ToArray(); + + using var testHandler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(testHandler, disposeHandler: false); + + var exportClient = new OtlpHttpExportClient( + new OtlpExporterOptions + { + Endpoint = new Uri("http://localhost:4318"), + Protocol = OtlpExportProtocol.HttpProtobuf, + Compression = OtlpExportCompression.Gzip, + }, + httpClient, + string.Empty); + + exportClient.SendExportRequest(payload, payload.Length, DateTime.UtcNow.AddSeconds(10)); + + var content = testHandler.HttpRequestContent; + + Assert.NotNull(content); + Assert.NotEmpty(content); + Assert.True(content.Length < payload.Length, "The payload was not compressed."); + + byte[] decompressed; + + using (var input = new MemoryStream(content)) + using (var gzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + gzip.CopyTo(output); + decompressed = output.ToArray(); + } + + Assert.NotEmpty(decompressed); + Assert.Equal(payload, decompressed); + + var request = testHandler.HttpRequestMessage; + + Assert.NotNull(request); + Assert.NotNull(request.Content); + Assert.Contains(request.Content.Headers, h => h.Key == "Content-Encoding" && h.Value.Contains("gzip")); + } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs index d64de24edbb..fb0ec22887b 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionTests.cs @@ -20,7 +20,9 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName, "1001", OtlpSpecConfigDefinitions.DefaultProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.DefaultCompressionEnvVarName, + "gzip"); internal static TestData LoggingData { get; } = new TestData( OtlpExporterOptionsConfigurationType.Logs, @@ -32,7 +34,9 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.LogsTimeoutEnvVarName, "1002", OtlpSpecConfigDefinitions.LogsProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.LogsCompressionEnvVarName, + "gzip"); internal static MetricsTestData MetricsData { get; } = new MetricsTestData( OtlpSpecConfigDefinitions.MetricsEndpointEnvVarName, @@ -44,6 +48,8 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable "1003", OtlpSpecConfigDefinitions.MetricsProtocolEnvVarName, "http/protobuf", + OtlpSpecConfigDefinitions.MetricsCompressionEnvVarName, + "gzip", OtlpSpecConfigDefinitions.MetricsTemporalityPreferenceEnvVarName, "Delta", OtlpSpecConfigDefinitions.MetricsDefaultHistogramAggregationEnvVarName, @@ -59,32 +65,38 @@ public class OtlpSpecConfigDefinitionTests : IEnumerable OtlpSpecConfigDefinitions.TracesTimeoutEnvVarName, "1004", OtlpSpecConfigDefinitions.TracesProtocolEnvVarName, - "http/protobuf"); + "http/protobuf", + OtlpSpecConfigDefinitions.TracesCompressionEnvVarName, + "gzip"); [Fact] public void VerifyKeyNamesMatchSpec() { + Assert.Equal("OTEL_EXPORTER_OTLP_COMPRESSION", DefaultData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_ENDPOINT", DefaultData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_HEADERS", DefaultData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_TIMEOUT", DefaultData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_PROTOCOL", DefaultData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TIMEOUT", DefaultData.TimeoutKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_COMPRESSION", LoggingData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", LoggingData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_HEADERS", LoggingData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", LoggingData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", LoggingData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", LoggingData.TimeoutKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_COMPRESSION", MetricsData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", MetricsData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_HEADERS", MetricsData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", MetricsData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", MetricsData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", MetricsData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", MetricsData.TemporalityKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION", MetricsData.HistogramAggregationKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_COMPRESSION", TracingData.CompressionKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", TracingData.EndpointKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_HEADERS", TracingData.HeadersKeyName); - Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", TracingData.TimeoutKeyName); Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", TracingData.ProtocolKeyName); + Assert.Equal("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", TracingData.TimeoutKeyName); } public IEnumerator GetEnumerator() @@ -152,7 +164,9 @@ public TestData( string timeoutKeyName, string timeoutValue, string protocolKeyName, - string protocolValue) + string protocolValue, + string compressionKeyName, + string compressionValue) { this.ConfigurationType = configurationType; this.EndpointKeyName = endpointKeyName; @@ -164,6 +178,8 @@ public TestData( this.TimeoutValue = timeoutValue; this.ProtocolKeyName = protocolKeyName; this.ProtocolValue = protocolValue; + this.CompressionKeyName = compressionKeyName; + this.CompressionValue = compressionValue; } public OtlpExporterOptionsConfigurationType ConfigurationType { get; } @@ -186,6 +202,10 @@ public TestData( public string ProtocolValue { get; } + public string CompressionKeyName { get; } + + public string CompressionValue { get; } + public IConfiguration ToConfiguration() => this.AddToConfiguration(new ConfigurationBuilder()).Build(); @@ -193,10 +213,11 @@ public ConfigurationBuilder AddToConfiguration(ConfigurationBuilder configuratio { Dictionary dictionary = new() { + [this.CompressionKeyName] = this.CompressionValue, [this.EndpointKeyName] = this.EndpointValue, [this.HeadersKeyName] = this.HeadersValue, - [this.TimeoutKeyName] = this.TimeoutValue, [this.ProtocolKeyName] = this.ProtocolValue, + [this.TimeoutKeyName] = this.TimeoutValue, }; this.OnAddToDictionary(dictionary); @@ -208,20 +229,22 @@ public ConfigurationBuilder AddToConfiguration(ConfigurationBuilder configuratio public void SetEnvVars() { + Environment.SetEnvironmentVariable(this.CompressionKeyName, this.CompressionValue); Environment.SetEnvironmentVariable(this.EndpointKeyName, this.EndpointValue); Environment.SetEnvironmentVariable(this.HeadersKeyName, this.HeadersValue); - Environment.SetEnvironmentVariable(this.TimeoutKeyName, this.TimeoutValue); Environment.SetEnvironmentVariable(this.ProtocolKeyName, this.ProtocolValue); + Environment.SetEnvironmentVariable(this.TimeoutKeyName, this.TimeoutValue); this.OnSetEnvVars(); } public void ClearEnvVars() { + Environment.SetEnvironmentVariable(this.CompressionKeyName, null); Environment.SetEnvironmentVariable(this.EndpointKeyName, null); Environment.SetEnvironmentVariable(this.HeadersKeyName, null); - Environment.SetEnvironmentVariable(this.TimeoutKeyName, null); Environment.SetEnvironmentVariable(this.ProtocolKeyName, null); + Environment.SetEnvironmentVariable(this.TimeoutKeyName, null); this.OnClearEnvVars(); } @@ -239,6 +262,13 @@ public void AssertMatches(IOtlpExporterOptions otlpExporterOptions) Assert.Equal(protocol, otlpExporterOptions.Protocol); + if (!OtlpExporterOptions.TryParseCompression(this.CompressionValue, out var compression)) + { + Assert.Fail(); + } + + Assert.Equal(compression, otlpExporterOptions.Compression); + var concreteOptions = otlpExporterOptions as OtlpExporterOptions; Assert.NotNull(concreteOptions); Assert.Equal(this.AppendSignalPathToEndpoint, concreteOptions.AppendSignalPathToEndpoint); @@ -269,6 +299,8 @@ public MetricsTestData( string timeoutValue, string protocolKeyName, string protocolValue, + string compressionKeyName, + string compressionValue, string temporalityKeyName, string temporalityValue, string? histogramAggregationKeyName = null, @@ -283,7 +315,9 @@ public MetricsTestData( timeoutKeyName, timeoutValue, protocolKeyName, - protocolValue) + protocolValue, + compressionKeyName, + compressionValue) { this.TemporalityKeyName = temporalityKeyName; this.TemporalityValue = temporalityValue; From dbbfb446d69ce39451dbe4ccde18033fc595a21a Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 10 Apr 2026 14:52:10 +0100 Subject: [PATCH 02/18] [OTLP] Avoid allocating array - Avoid allocating a buffer for every write of compressed gRPC data. - Simplify `IsTransientNetworkError()`. --- .../ExportClient/OtlpGrpcExportClient.cs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 368352022ff..d864fc81242 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -8,6 +8,7 @@ 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; @@ -33,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) { @@ -42,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) { @@ -196,8 +206,12 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt // bytes 5+ - Gzip-compressed protobuf payload. var compressedStream = new MemoryStream(); - // Reserve space for the 5-byte gRPC frame header. - compressedStream.Write([0, 0, 0, 0, 0], 0, GrpcMessageHeaderSize); + // 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)) { @@ -222,9 +236,5 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt } 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 }; } From c18093b97593e04bb634bd3ca602c13405e0b67a Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 10 Apr 2026 14:57:32 +0100 Subject: [PATCH 03/18] [OTLP] Extend test coverage Add coverage for different ways to specify the compression value. --- .../OtlpExporterOptions.cs | 2 +- .../OtlpExporterOptionsTests.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index aa7d9d9e5bc..a5433a7f0c9 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -231,7 +231,7 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( string protocolEnvVarKey, string headersEnvVarKey, string timeoutEnvVarKey, - string? compressionEnvVarKey) + string compressionEnvVarKey) { if (configuration.TryGetUriValue(OpenTelemetryProtocolExporterEventSource.Log, endpointEnvVarKey, out var endpoint)) { diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index 80418d24f81..6fc2be67a03 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs @@ -167,6 +167,40 @@ public void OtlpExporterOptions_SetterOverridesEnvironmentVariable() 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] public void OtlpExporterOptions_EndpointGetterUsesProtocolWhenNull() { From 3109a874a0cf63c5c03d46574f6e9fa2beed87ed Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 10 Apr 2026 18:52:56 +0100 Subject: [PATCH 04/18] [OTLP] Address feedback - Ensure only one `grpc-encoding` header. - Remove redundant null checks. --- .../Implementation/ExportClient/OtlpGrpcExportClient.cs | 3 ++- .../OtlpExporterOptions.cs | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index d864fc81242..7e9f7848181 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -67,7 +67,8 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten if (this.CompressionEnabled) { - httpRequest.Headers.Add("grpc-encoding", "gzip"); + httpRequest.Headers.Remove("grpc-encoding"); + httpRequest.Headers.TryAddWithoutValidation("grpc-encoding", "gzip"); } httpResponse = this.SendHttpRequest(httpRequest, cancellationToken); diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index a5433a7f0c9..4055a392503 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -200,7 +200,7 @@ internal bool HasData internal static bool TryParseCompression(string value, out OtlpExportCompression result) { - switch (value?.Trim().ToUpperInvariant()) + switch (value.Trim().ToUpperInvariant()) { case "NONE": result = OtlpExportCompression.None; @@ -258,8 +258,7 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( this.TimeoutMilliseconds = timeout; } - if (compressionEnvVarKey != null - && configuration.TryGetValue( + if (configuration.TryGetValue( OpenTelemetryProtocolExporterEventSource.Log, compressionEnvVarKey, TryParseCompression, From 09a0c687406b00b4ba4537c2bb17381cc7338a53 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 21 Apr 2026 14:33:48 +0100 Subject: [PATCH 05/18] [OLTP] Fix lint warning Remove trailing spaces. --- .../Implementation/ExportClient/OtlpGrpcExportClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index f63624a4d60..275b53516c3 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -49,7 +49,6 @@ 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]; From 3b9d5c87ab7db696a286f4cbe8af4c7e7e0f2b61 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 21 Apr 2026 14:34:23 +0100 Subject: [PATCH 06/18] [OLTP] Fix spacing Add missing blank line. --- .../Implementation/ExportClient/OtlpGrpcExportClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 275b53516c3..3b688d3a656 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -49,6 +49,7 @@ 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]; From 848ab7a957dd75322574b43f4793ac58e71e70c6 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 13:17:19 +0100 Subject: [PATCH 07/18] [OTLP] Add verbose logs for compression Log the compressed and uncompressed sizes when using GZip. --- .../ExportClient/OtlpGrpcExportClient.cs | 2 ++ .../ExportClient/OtlpHttpExportClient.cs | 2 ++ .../OpenTelemetryProtocolExporterEventSource.cs | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 3b688d3a656..3c8cec201d7 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -234,6 +234,8 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt compressedStream.Position = 0; + OpenTelemetryProtocolExporterEventSource.Log.CompressedGrpcPayload(contentLength, compressedStream.Length); + var content = new StreamContent(compressedStream); content.Headers.ContentType = this.MediaTypeHeader; return content; diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs index f0da95eec76..bede74d3444 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs @@ -71,6 +71,8 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt compressedStream.Position = 0; + OpenTelemetryProtocolExporterEventSource.Log.CompressedHttpPayload(contentLength, compressedStream.Length); + var content = new StreamContent(compressedStream); content.Headers.ContentType = this.MediaTypeHeader; diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index 9439a7344cc..ebff134e201 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 from {0} bytes to {1} bytes.", + Level = EventLevel.Verbose)] + internal void CompressedGrpcPayload(long uncompressedLength, long compressedLength) => + this.WriteEvent(37, uncompressedLength, compressedLength); + + [Event( + 38, + Message = "HTTP export payload content was compressed from {0} bytes to {1} bytes.", + Level = EventLevel.Verbose)] + internal void CompressedHttpPayload(long uncompressedLength, long compressedLength) => + this.WriteEvent(38, uncompressedLength, compressedLength); + private static string RedactEndpointUri(Uri endpoint) => endpoint.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped); } From d2c4f772102b01ec5c4ac5efca2f4925499a452a Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 14:24:40 +0100 Subject: [PATCH 08/18] [OTLP] Add pooled memory stream Add an implementation of `Stream` that uses pooled buffers. --- .../ExportClient/OtlpGrpcExportClient.cs | 4 + .../ExportClient/OtlpHttpExportClient.cs | 5 + .../ExportClient/PooledBufferStream.cs | 377 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 3c8cec201d7..1cb1342beaf 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -208,7 +208,11 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt // 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 diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs index bede74d3444..261f587c1ef 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs @@ -63,7 +63,12 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt 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); 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..ed053394f10 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs @@ -0,0 +1,377 @@ +// 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 CanTimeout => false; + + /// + 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 buf = this.buffer; + this.buffer = []; + this.length = 0; + this.position = 0; + + if (buf is not null) + { + this.pool.Return(buf); + } + } + + 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 From 870c70af879d36cf910274d9790e9ee899e5f6e3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 14:25:30 +0100 Subject: [PATCH 09/18] [OTLP] Rename enum member Rename to `GZip` to match `GZipStream`. --- .../.publicApi/Stable/PublicAPI.Unshipped.txt | 2 +- .../Implementation/ExportClient/OtlpExportClient.cs | 2 +- .../OtlpExportCompression.cs | 2 +- .../OtlpExporterOptions.cs | 2 +- .../OtlpExporterOptionsTests.cs | 8 ++++---- .../OtlpGrpcExportClientTests.cs | 4 ++-- .../OtlpHttpExportClientTests.cs | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt index 5c7ce12865f..8840de75e96 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/.publicApi/Stable/PublicAPI.Unshipped.txt @@ -1,6 +1,6 @@ #nullable enable OpenTelemetry.Exporter.OtlpExportCompression -OpenTelemetry.Exporter.OtlpExportCompression.Gzip = 1 -> 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/Implementation/ExportClient/OtlpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs index 413e425f0dd..3af8b2f8be1 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpExportClient.cs @@ -44,7 +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; + this.CompressionEnabled = options.Compression == OtlpExportCompression.GZip; } internal HttpClient HttpClient { get; } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs index c16c054ff00..04881a05b1d 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExportCompression.cs @@ -19,5 +19,5 @@ public enum OtlpExportCompression /// /// Compress with GZip. /// - Gzip = 1, + GZip = 1, } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 4055a392503..470bf90c403 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -207,7 +207,7 @@ internal static bool TryParseCompression(string value, out OtlpExportCompression return true; case "GZIP": - result = OtlpExportCompression.Gzip; + result = OtlpExportCompression.GZip; return true; default: diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index 6fc2be67a03..762aab51d45 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs @@ -164,15 +164,15 @@ 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); + 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("gzip", OtlpExportCompression.GZip)] + [InlineData("GZip", OtlpExportCompression.GZip)] + [InlineData("GZIP", OtlpExportCompression.GZip)] [InlineData("none", OtlpExportCompression.None)] [InlineData("None", OtlpExportCompression.None)] [InlineData("NONE", OtlpExportCompression.None)] diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs index 6b0118b0190..ba348b7336b 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpGrpcExportClientTests.cs @@ -61,7 +61,7 @@ public void SendExportRequest_GrpcExport_GzipCompression_FrameLengthMatchesCompr new OtlpExporterOptions { Endpoint = new Uri("http://localhost:4317"), - Compression = OtlpExportCompression.Gzip, + Compression = OtlpExportCompression.GZip, }, httpClient, string.Empty); @@ -91,7 +91,7 @@ public void SendExportRequest_GrpcExport_GzipCompression_PayloadDecompressesToOr new OtlpExporterOptions { Endpoint = new Uri("http://localhost:4317"), - Compression = OtlpExportCompression.Gzip, + Compression = OtlpExportCompression.GZip, }, httpClient, string.Empty); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs index a2dd781cbb9..450f13f032d 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpHttpExportClientTests.cs @@ -80,7 +80,7 @@ public void SendExportRequest_WithGzipCompression_IsCompressed() { Endpoint = new Uri("http://localhost:4318"), Protocol = OtlpExportProtocol.HttpProtobuf, - Compression = OtlpExportCompression.Gzip, + Compression = OtlpExportCompression.GZip, }, httpClient, string.Empty); From 905e4ca85bf780990b18b06708ff31126cf8324a Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 14:27:11 +0100 Subject: [PATCH 10/18] [OTLP] Update README Add documentation for compression. --- .../README.md | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) 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 From 048b1ac7689cb99939c429fa90a67fbeb154190b Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 14:37:48 +0100 Subject: [PATCH 11/18] [OTLP] Add benchmark for compression Add a benchmark for OTLP compression. --- .../OtlpExporterCompressionBenchmarks.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs diff --git a/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs new file mode 100644 index 00000000000..e67baa8a4df --- /dev/null +++ b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs @@ -0,0 +1,74 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +extern alias OpenTelemetryProtocol; + +using System.Diagnostics; +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, + }; + + 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_Batching() + { + 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)); + } + } +} From 93dd8df7113dc8f4d0b8a6da55718eeb4d4091ef Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 15:28:36 +0100 Subject: [PATCH 12/18] [OTLP] Fix compression benchmarks Stub-out the `HttpClient` requests. --- .../OtlpExporterCompressionBenchmarks.cs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs index e67baa8a4df..9a2cea4304c 100644 --- a/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs +++ b/test/Benchmarks/Exporter/OtlpExporterCompressionBenchmarks.cs @@ -4,6 +4,11 @@ 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; @@ -36,6 +41,7 @@ public void GlobalSetup() { Compression = this.Compression, Protocol = OtlpExportProtocol.HttpProtobuf, + HttpClientFactory = () => new HttpClient(new StubHttpClientHandler(), true), }; this.exporter = new OtlpTraceExporter( @@ -59,7 +65,7 @@ public void GlobalCleanup() } [Benchmark] - public void OtlpExporter_Batching() + public void OtlpExporter_Compression() { for (var i = 0; i < NumberOfBatches; i++) { @@ -71,4 +77,34 @@ public void OtlpExporter_Batching() 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 + } } From 9f8b3f52a24a92d1dc370eb334356170a4b06507 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 15:29:43 +0100 Subject: [PATCH 13/18] [Infra] Use top-level program Use a top-level program for benchmarks and return whether they have succeeded to the caller. --- test/Benchmarks/Program.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/test/Benchmarks/Program.cs b/test/Benchmarks/Program.cs index 91a947733ca..49639ea895d 100644 --- a/test/Benchmarks/Program.cs +++ b/test/Benchmarks/Program.cs @@ -3,12 +3,5 @@ using BenchmarkDotNet.Running; -namespace OpenTelemetry.Benchmarks; - -internal static class Program -{ - public static void Main(string[] args) - { - BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); - } -} +var summary = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +return summary.SelectMany((p) => p.Reports).Any((p) => !p.Success) ? 1 : 0; From 43107f9b3fb9293e196ca14a1d1e7262906a5cdb Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 15:39:55 +0100 Subject: [PATCH 14/18] [OTLP] Remove redundant override `Stream.CanTimeout` is already `false`. --- .../Implementation/ExportClient/PooledBufferStream.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs index ed053394f10..176e1842814 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs @@ -39,9 +39,6 @@ public PooledBufferStream(int initialCapacity = 0, ArrayPool? pool = null) /// public override bool CanSeek => !this.disposed; - /// - public override bool CanTimeout => false; - /// public override bool CanWrite => !this.disposed; From c07c97a94fec9deeb470b06d170161d2007c7c67 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 15:49:21 +0100 Subject: [PATCH 15/18] [OTLP] Extend test coverage Add coverage for `PooledBufferStream` that isn't already covered by other tests. --- .../ExportClient/PooledBufferStreamTests.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/Implementation/ExportClient/PooledBufferStreamTests.cs 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 From 4dbc8c3b388c5f37c6f4242f694b55c95177070c Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 15:51:51 +0100 Subject: [PATCH 16/18] [OTLP] Rename variable Rename `buf` to `rented` and fix trying to return an empty buffer. --- .../Implementation/ExportClient/PooledBufferStream.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs index 176e1842814..ff94d8b4265 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/PooledBufferStream.cs @@ -268,14 +268,14 @@ protected override void Dispose(bool disposing) if (disposing) { - var buf = this.buffer; + var rented = this.buffer; this.buffer = []; this.length = 0; this.position = 0; - if (buf is not null) + if (rented is { Length: > 0 }) { - this.pool.Return(buf); + this.pool.Return(rented); } } From a0d9f15901bf6b9ede1c7187c36c372530076f40 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Mon, 27 Apr 2026 16:20:53 +0100 Subject: [PATCH 17/18] [OTLP] Log compression type Log the compression type in the event source. --- .../ExportClient/OtlpGrpcExportClient.cs | 2 +- .../ExportClient/OtlpHttpExportClient.cs | 2 +- .../OpenTelemetryProtocolExporterEventSource.cs | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 1cb1342beaf..cb1326bbb39 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -238,7 +238,7 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt compressedStream.Position = 0; - OpenTelemetryProtocolExporterEventSource.Log.CompressedGrpcPayload(contentLength, compressedStream.Length); + OpenTelemetryProtocolExporterEventSource.Log.CompressedGrpcPayload("gzip", contentLength, compressedStream.Length); var content = new StreamContent(compressedStream); content.Headers.ContentType = this.MediaTypeHeader; diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs index 261f587c1ef..1cfef04b053 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpHttpExportClient.cs @@ -76,7 +76,7 @@ protected override HttpContent CreateHttpContent(byte[] buffer, int contentLengt compressedStream.Position = 0; - OpenTelemetryProtocolExporterEventSource.Log.CompressedHttpPayload(contentLength, compressedStream.Length); + OpenTelemetryProtocolExporterEventSource.Log.CompressedHttpPayload("gzip", contentLength, compressedStream.Length); var content = new StreamContent(compressedStream); diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index ebff134e201..1b66759030e 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -308,17 +308,17 @@ internal void SecureHttpClientCreationFailed(string exception) => [Event( 37, - Message = "gRPC export payload content was compressed from {0} bytes to {1} bytes.", + Message = "gRPC export payload content was compressed with '{0}' from {1} bytes to {2} bytes.", Level = EventLevel.Verbose)] - internal void CompressedGrpcPayload(long uncompressedLength, long compressedLength) => - this.WriteEvent(37, uncompressedLength, compressedLength); + internal void CompressedGrpcPayload(string compression, long uncompressedLength, long compressedLength) => + this.WriteEvent(37, compression, uncompressedLength, compressedLength); [Event( 38, - Message = "HTTP export payload content was compressed from {0} bytes to {1} bytes.", + Message = "HTTP export payload content was compressed with '{0}' from {1} bytes to {2} bytes.", Level = EventLevel.Verbose)] - internal void CompressedHttpPayload(long uncompressedLength, long compressedLength) => - this.WriteEvent(38, uncompressedLength, compressedLength); + 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); From 04349f2678517cfe9b379a407ab380eb5d8d716f Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 28 Apr 2026 10:11:28 +0100 Subject: [PATCH 18/18] [OTLP] Fix CHANGELOG Update PR number. --- src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md index f2564567052..bb0b5e16796 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/CHANGELOG.md @@ -13,7 +13,7 @@ Notes](../../RELEASENOTES.md). * 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)) + ([#7055](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7055)) ## 1.15.3