diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/GrpcProtocolHelper.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/GrpcProtocolHelper.cs
new file mode 100644
index 00000000000..2932619fa49
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/GrpcProtocolHelper.cs
@@ -0,0 +1,403 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+// Includes work from:
+/*
+* Copyright 2019 The gRPC Authors
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using System.Net;
+using System.Net.Http.Headers;
+using Grpc.Core;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+
+// https://github.com/grpc/grpc-dotnet/blob/master/src/Grpc.Net.Client/Internal/GrpcProtocolHelpers.cs
+internal class GrpcProtocolHelper
+{
+ private const string GrpcStatusHeader = "grpc-status";
+ private const string GrpcMessageHeader = "grpc-message";
+ private const string MessageEncodingHeader = "grpc-encoding";
+ private const string MessageAcceptEncodingHeader = "grpc-accept-encoding";
+ private const string GrpcContentType = "application/grpc";
+
+ private static readonly Version Http2Version = new Version(2, 0);
+
+ internal static void ProcessHttpResponse(HttpResponseMessage httpResponse, out RpcException? rpcException)
+ {
+ rpcException = null;
+ var status = ValidateHeaders(httpResponse, out var trailers);
+
+ if (status != null && status.HasValue)
+ {
+ if (status.Value.StatusCode == StatusCode.OK)
+ {
+ // https://github.com/grpc/grpc-dotnet/blob/1416340c85bb5925b5fed0c101e7e6de71e367e0/src/Grpc.Net.Client/Internal/GrpcCall.cs#L526-L527
+ // Status OK should always be set as part of Trailers.
+ // Change the status code to a more accurate status.
+ // This is consistent with Grpc.Core client behavior.
+ status = new Status(StatusCode.Internal, "Failed to deserialize response message. The response header contains a gRPC status of OK, which means any message returned to the client for this call should be ignored. A unary or client streaming gRPC call must have a response message, which makes this response invalid.");
+ rpcException = new RpcException(status.Value, trailers ?? Metadata.Empty);
+ }
+ else
+ {
+ rpcException = new RpcException(status.Value, trailers ?? Metadata.Empty);
+ }
+ }
+
+ if (status == null)
+ {
+ // TODO: We need to read the response message here (content)
+ // if the returned status is OK but content is null then change status to internal error.
+ // ref: https://github.com/grpc/grpc-dotnet/blob/1416340c85bb5925b5fed0c101e7e6de71e367e0/src/Grpc.Net.Client/Internal/GrpcCall.cs#L558-L575
+
+ // Check to see if the status is part of trailers
+ // TODO: Proper handling of isBrowser/isWinHttp
+ status = GetResponseStatus(httpResponse, false, false);
+
+ if (status != null && status.HasValue && status.Value.StatusCode != StatusCode.OK)
+ {
+ rpcException = new RpcException(status.Value, trailers ?? Metadata.Empty);
+ }
+ }
+ }
+
+ private static bool TryGetStatusCore(HttpHeaders httpHeaders, out Status? status)
+ {
+ var grpcStatus = GetHeaderValue(httpHeaders, GrpcStatusHeader);
+
+ // grpc-status is a required trailer
+ if (grpcStatus == null)
+ {
+ status = null;
+ return false;
+ }
+
+ int statusValue;
+ if (!int.TryParse(grpcStatus, out statusValue))
+ {
+ throw new InvalidOperationException("Unexpected grpc-status value: " + grpcStatus);
+ }
+
+ // grpc-message is optional
+ // Always read the gRPC message from the same headers collection as the status
+ var grpcMessage = GetHeaderValue(httpHeaders, GrpcMessageHeader);
+
+ if (!string.IsNullOrEmpty(grpcMessage))
+ {
+ // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#responses
+ // The value portion of Status-Message is conceptually a Unicode string description of the error,
+ // physically encoded as UTF-8 followed by percent-encoding.
+ grpcMessage = Uri.UnescapeDataString(grpcMessage);
+ }
+
+ status = new Status((StatusCode)statusValue, grpcMessage ?? string.Empty);
+ return true;
+ }
+
+ private static Status? ValidateHeaders(HttpResponseMessage httpResponse, out Metadata? trailers)
+ {
+ // gRPC status can be returned in the header when there is no message (e.g. unimplemented status)
+ // An explicitly specified status header has priority over other failing statuses
+ if (TryGetStatusCore(httpResponse.Headers, out var status))
+ {
+ // Trailers are in the header because there is no message.
+ // Note that some default headers will end up in the trailers (e.g. Date, Server).
+ trailers = BuildMetadata(httpResponse.Headers);
+ return status;
+ }
+
+ trailers = null;
+
+ // ALPN negotiation is sending HTTP/1.1 and HTTP/2.
+ // Check that the response wasn't downgraded to HTTP/1.1.
+ if (httpResponse.Version < Http2Version)
+ {
+ return new Status(StatusCode.Internal, $"Bad gRPC response. Response protocol downgraded to HTTP/{httpResponse.Version.ToString(2)}.");
+ }
+
+ if (httpResponse.StatusCode != HttpStatusCode.OK)
+ {
+ var statusCode = MapHttpStatusToGrpcCode(httpResponse.StatusCode);
+ return new Status(statusCode, "Bad gRPC response. HTTP status code: " + (int)httpResponse.StatusCode);
+ }
+
+ // Don't access Headers.ContentType property because it is not threadsafe.
+ var contentType = GetHeaderValue(httpResponse.Content?.Headers, "Content-Type");
+ if (contentType == null)
+ {
+ return new Status(StatusCode.Cancelled, "Bad gRPC response. Response did not have a content-type header.");
+ }
+
+ if (!IsContentType(GrpcContentType, contentType))
+ {
+ return new Status(StatusCode.Cancelled, "Bad gRPC response. Invalid content-type value: " + contentType);
+ }
+
+ // Call is still in progress
+ return null;
+ }
+
+ private static StatusCode MapHttpStatusToGrpcCode(HttpStatusCode httpStatusCode)
+ {
+ switch (httpStatusCode)
+ {
+ case HttpStatusCode.BadRequest: // 400
+#if !NETSTANDARD2_0 && !NET462
+ case HttpStatusCode.RequestHeaderFieldsTooLarge: // 431
+#else
+ case (HttpStatusCode)431:
+#endif
+ return StatusCode.Internal;
+ case HttpStatusCode.Unauthorized: // 401
+ return StatusCode.Unauthenticated;
+ case HttpStatusCode.Forbidden: // 403
+ return StatusCode.PermissionDenied;
+ case HttpStatusCode.NotFound: // 404
+ return StatusCode.Unimplemented;
+#if !NETSTANDARD2_0 && !NET462
+ case HttpStatusCode.TooManyRequests: // 429
+#else
+ case (HttpStatusCode)429:
+#endif
+ case HttpStatusCode.BadGateway: // 502
+ case HttpStatusCode.ServiceUnavailable: // 503
+ case HttpStatusCode.GatewayTimeout: // 504
+ return StatusCode.Unavailable;
+ default:
+ if ((int)httpStatusCode >= 100 && (int)httpStatusCode < 200)
+ {
+ // 1xx. These headers should have been ignored.
+ return StatusCode.Internal;
+ }
+
+ return StatusCode.Unknown;
+ }
+ }
+
+ private static Metadata BuildMetadata(HttpHeaders responseHeaders)
+ {
+ var headers = new Metadata();
+
+#if NET6_0_OR_GREATER
+ // Use NonValidated to avoid race-conditions and because it is faster.
+ foreach (var header in responseHeaders.NonValidated)
+#else
+ foreach (var header in responseHeaders)
+#endif
+ {
+ if (ShouldSkipHeader(header.Key))
+ {
+ continue;
+ }
+
+ foreach (var value in header.Value)
+ {
+ if (header.Key.EndsWith(Metadata.BinaryHeaderSuffix, StringComparison.OrdinalIgnoreCase))
+ {
+ headers.Add(header.Key, ParseBinaryHeader(value));
+ }
+ else
+ {
+ headers.Add(header.Key, value);
+ }
+ }
+ }
+
+ return headers;
+ }
+
+ private static byte[] ParseBinaryHeader(string base64)
+ {
+ string decodable;
+ switch (base64.Length % 4)
+ {
+ case 0:
+ // base64 has the required padding
+ decodable = base64;
+ break;
+ case 2:
+ // 2 chars padding
+ decodable = base64 + "==";
+ break;
+ case 3:
+ // 3 chars padding
+ decodable = base64 + "=";
+ break;
+ default:
+ // length%4 == 1 should be illegal
+ throw new FormatException("Invalid Base-64 header value.");
+ }
+
+ return Convert.FromBase64String(decodable);
+ }
+
+ private static bool ShouldSkipHeader(string name)
+ {
+ if (name.Length == 0)
+ {
+ return false;
+ }
+
+ switch (name[0])
+ {
+ case ':':
+ // ASP.NET Core includes pseudo headers in the set of request headers
+ // whereas, they are not in gRPC implementations. We will filter them
+ // out when we construct the list of headers on the context.
+ return true;
+ case 'g':
+ case 'G':
+ // Exclude known grpc headers. This matches Grpc.Core client behavior.
+ return string.Equals(name, GrpcStatusHeader, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(name, GrpcMessageHeader, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(name, MessageEncodingHeader, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(name, MessageAcceptEncodingHeader, StringComparison.OrdinalIgnoreCase);
+ case 'c':
+ case 'C':
+ // Exclude known HTTP headers. This matches Grpc.Core client behavior.
+ return string.Equals(name, "content-encoding", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(name, "content-type", StringComparison.OrdinalIgnoreCase);
+ default:
+ return false;
+ }
+ }
+
+ private static Status GetResponseStatus(HttpResponseMessage httpResponse, bool isBrowser, bool isWinHttp)
+ {
+ Status? status;
+ try
+ {
+ if (!TryGetStatusCore(httpResponse.TrailingHeaders(), out status))
+ {
+ var detail = "No grpc-status found on response.";
+ if (isBrowser)
+ {
+ detail += " If the gRPC call is cross domain then CORS must be correctly configured. Access-Control-Expose-Headers needs to include 'grpc-status' and 'grpc-message'.";
+ }
+
+ if (isWinHttp)
+ {
+ detail += " Using gRPC with WinHttp has Windows and package version requirements. See https://aka.ms/aspnet/grpc/netstandard for details.";
+ }
+
+ status = new Status(StatusCode.Cancelled, detail);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Handle error from parsing badly formed status
+ status = new Status(StatusCode.Cancelled, ex.Message, ex);
+ }
+
+ return status.HasValue ? status.Value : default;
+ }
+
+ private static string? GetHeaderValue(HttpHeaders? headers, string name, bool first = false)
+ {
+ if (headers == null)
+ {
+ return null;
+ }
+
+#if NET6_0_OR_GREATER
+ if (!headers.NonValidated.TryGetValues(name, out var values))
+ {
+ return null;
+ }
+
+ using (var e = values.GetEnumerator())
+ {
+ if (!e.MoveNext())
+ {
+ return null;
+ }
+
+ var result = e.Current;
+ if (!e.MoveNext())
+ {
+ return result;
+ }
+
+ if (first)
+ {
+ return result;
+ }
+ }
+
+ throw new InvalidOperationException($"Multiple {name} headers.");
+#else
+ if (!headers.TryGetValues(name, out var values))
+ {
+ return null;
+ }
+
+ // HttpHeaders appears to always return an array, but fallback to converting values to one just in case
+ var valuesArray = values as string[] ?? values.ToArray();
+
+ switch (valuesArray.Length)
+ {
+ case 0:
+ return null;
+ case 1:
+ return valuesArray[0];
+ default:
+ if (first)
+ {
+ return valuesArray[0];
+ }
+
+ throw new InvalidOperationException($"Multiple {name} headers.");
+ }
+#endif
+ }
+
+ private static bool IsContentType(string contentType, string? s)
+ {
+ if (s == null)
+ {
+ return false;
+ }
+
+ if (!s.StartsWith(contentType, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (s.Length == contentType.Length)
+ {
+ // Exact match
+ return true;
+ }
+
+ // Support variations on the content-type (e.g. +proto, +json)
+ var nextChar = s[contentType.Length];
+ if (nextChar == ';')
+ {
+ return true;
+ }
+
+ if (nextChar == '+')
+ {
+ // Accept any message format. Marshaller could be set to support third-party formats
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/IExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/IExportClient.cs
new file mode 100644
index 00000000000..cdfd67f70a6
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/IExportClient.cs
@@ -0,0 +1,37 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+
+/// Export client interface.
+internal interface IExportClient
+{
+ ///
+ /// Method for sending export request to the server.
+ ///
+ /// The request to send to the server.
+ /// length of the content.
+ /// The deadline time in utc for export request to finish.
+ /// An optional token for canceling the call.
+ /// .
+ ExportClientResponse SendExportRequest(byte[] request, int contentLength, DateTime deadlineUtc, CancellationToken cancellationToken = default);
+
+ HttpRequestMessage CreateHttpRequest(byte[] request, int contentLength);
+
+ ///
+ /// Method for shutting down the export client.
+ ///
+ ///
+ /// The number of milliseconds to wait, or Timeout.Infinite to
+ /// wait indefinitely.
+ ///
+ ///
+ /// Returns true if shutdown succeeded; otherwise, false.
+ ///
+ bool Shutdown(int timeoutMilliseconds);
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpGrpcExportClient.cs
new file mode 100644
index 00000000000..0cea1dcff78
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpGrpcExportClient.cs
@@ -0,0 +1,145 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using System.Buffers.Binary;
+using System.Net.Http.Headers;
+using Grpc.Core;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+using OpenTelemetry.Internal;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+
+/// Base class for sending OTLP export request over Grpc.
+internal class OtlpGrpcExportClient : IExportClient
+{
+ internal const string ErrorStartingCallMessage = "Error starting gRPC call.";
+ private static readonly MediaTypeHeaderValue MediaHeaderValue = new MediaTypeHeaderValue("application/grpc");
+ private static readonly Version Http2RequestVersion = new Version(2, 0);
+ private static readonly ExportClientHttpResponse SuccessExportResponse = new ExportClientHttpResponse(success: true, deadlineUtc: default, response: null, exception: null);
+
+ internal OtlpGrpcExportClient(OtlpExporterOptions options, HttpClient httpClient, string signalPath)
+ {
+ Guard.ThrowIfNull(options);
+ Guard.ThrowIfNull(httpClient);
+ Guard.ThrowIfNull(signalPath);
+ Guard.ThrowIfInvalidTimeout(options.TimeoutMilliseconds);
+
+ Uri exporterEndpoint = options.Endpoint.AppendPathIfNotPresent(signalPath);
+ this.Endpoint = new UriBuilder(exporterEndpoint).Uri;
+ this.Headers = options.GetHeaders>((d, k, v) => d.Add(k, v));
+ this.HttpClient = httpClient;
+ }
+
+ internal HttpClient HttpClient { get; }
+
+ internal Uri Endpoint { get; set; }
+
+ internal IReadOnlyDictionary Headers { get; }
+
+ public ExportClientResponse SendExportRequest(byte[] exportRequest, int contentLength, DateTime deadlineUtc, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var httpRequest = this.CreateHttpRequest(exportRequest, contentLength);
+
+ using var httpResponse = this.SendHttpRequest(httpRequest, cancellationToken);
+
+ GrpcProtocolHelper.ProcessHttpResponse(httpResponse, out var rpcException);
+
+ if (rpcException != null)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.FailedToReachCollector(this.Endpoint, rpcException);
+
+ return new ExportClientGrpcResponse(success: false, deadlineUtc: deadlineUtc, exception: rpcException);
+ }
+
+ // We do not need to return back response and deadline for successful response so using cached value.
+ return SuccessExportResponse;
+ }
+ catch (Exception ex)
+ {
+ // https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync?view=net-8.0#remarks
+ RpcException? rpcException = null;
+ if (ex is HttpRequestException)
+ {
+ var status = new Status(StatusCode.Unavailable, ErrorStartingCallMessage + " " + ex.Message, ex);
+
+ rpcException = new RpcException(status);
+
+ OpenTelemetryProtocolExporterEventSource.Log.FailedToReachCollector(this.Endpoint, rpcException);
+
+ return new ExportClientGrpcResponse(success: false, deadlineUtc: deadlineUtc, exception: rpcException);
+ }
+ else if (ex is TaskCanceledException)
+ {
+ // grpc-dotnet sets the timer for tracking deadline.
+ // https://github.com/grpc/grpc-dotnet/blob/1416340c85bb5925b5fed0c101e7e6de71e367e0/src/Grpc.Net.Client/Internal/GrpcCall.cs#L799-L803
+ // Utilizing the inner exception here to determine deadline exceeded related failures.
+ // https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync?view=net-8.0#remarks
+ if (ex.InnerException is TimeoutException)
+ {
+ var status = new Status(StatusCode.DeadlineExceeded, string.Empty);
+
+ // TODO: pre-allocate
+ rpcException = new RpcException(status);
+
+ OpenTelemetryProtocolExporterEventSource.Log.FailedToReachCollector(this.Endpoint, rpcException);
+
+ return new ExportClientGrpcResponse(success: false, deadlineUtc: deadlineUtc, exception: rpcException);
+ }
+ }
+
+ return new ExportClientGrpcResponse(success: false, deadlineUtc: deadlineUtc, exception: ex);
+
+ // TODO: Handle additional exception types (OperationCancelledException)
+ }
+ }
+
+ public bool Shutdown(int timeoutMilliseconds)
+ {
+ this.HttpClient.CancelPendingRequests();
+ return true;
+ }
+
+ public HttpRequestMessage CreateHttpRequest(byte[] exportRequest, int contentLength)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, this.Endpoint);
+ request.Version = Http2RequestVersion;
+
+#if NET6_0_OR_GREATER
+ request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
+#endif
+
+ foreach (var header in this.Headers)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+
+ // Grpc payload consists of 3 parts
+ // byte 0 - Specifying if the payload is compressed.
+ // 1-4 byte - Specifies the length of payload in big endian format.
+ // 5 and above - Protobuf serialized data.
+ Span data = new Span(exportRequest, 1, 4);
+ var dataLength = contentLength - 5;
+ BinaryPrimitives.WriteUInt32BigEndian(data, (uint)dataLength);
+
+ // TODO: Support compression.
+
+ request.Content = new ByteArrayContent(exportRequest, 0, contentLength);
+ request.Content.Headers.ContentType = MediaHeaderValue;
+
+ return request;
+ }
+
+ protected HttpResponseMessage SendHttpRequest(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ // grpc-dotnet calls specifies the HttpCompletion.ResponseHeadersRead.
+ // However, it is useful specifically for streaming calls?
+ // https://github.com/grpc/grpc-dotnet/blob/1416340c85bb5925b5fed0c101e7e6de71e367e0/src/Grpc.Net.Client/Internal/GrpcCall.cs#L485-L486
+ return this.HttpClient.SendAsync(request, cancellationToken).GetAwaiter().GetResult();
+ }
+}
+
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpHttpExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpHttpExportClient.cs
new file mode 100644
index 00000000000..9c6f4fb7419
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/OtlpHttpExportClient.cs
@@ -0,0 +1,96 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using System.Net.Http.Headers;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+using OpenTelemetry.Internal;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+
+/// Base class for sending OTLP export request over HTTP.
+internal class OtlpHttpExportClient : IExportClient
+{
+ private static readonly ExportClientHttpResponse SuccessExportResponse = new ExportClientHttpResponse(success: true, deadlineUtc: default, response: null, exception: null);
+ private static readonly MediaTypeHeaderValue MediaHeaderValue = new MediaTypeHeaderValue("application/x-protobuf");
+
+ internal OtlpHttpExportClient(OtlpExporterOptions options, HttpClient httpClient, string signalPath)
+ {
+ Guard.ThrowIfNull(options);
+ Guard.ThrowIfNull(httpClient);
+ Guard.ThrowIfNull(signalPath);
+ Guard.ThrowIfInvalidTimeout(options.TimeoutMilliseconds);
+
+ Uri exporterEndpoint = (options.AppendSignalPathToEndpoint || options.Protocol == OtlpExportProtocol.Grpc)
+ ? options.Endpoint.AppendPathIfNotPresent(signalPath)
+ : options.Endpoint;
+ this.Endpoint = new UriBuilder(exporterEndpoint).Uri;
+ this.Headers = options.GetHeaders>((d, k, v) => d.Add(k, v));
+ this.HttpClient = httpClient;
+ }
+
+ internal HttpClient HttpClient { get; }
+
+ internal Uri Endpoint { get; set; }
+
+ internal IReadOnlyDictionary Headers { get; }
+
+ public ExportClientResponse SendExportRequest(byte[] exportRequest, int contentLength, DateTime deadlineUtc, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ using var httpRequest = this.CreateHttpRequest(exportRequest, contentLength);
+
+ using var httpResponse = this.SendHttpRequest(httpRequest, cancellationToken);
+
+ try
+ {
+ httpResponse.EnsureSuccessStatusCode();
+ }
+ catch (HttpRequestException ex)
+ {
+ return new ExportClientHttpResponse(success: false, deadlineUtc: deadlineUtc, response: httpResponse, ex);
+ }
+
+ // We do not need to return back response and deadline for successful response so using cached value.
+ return SuccessExportResponse;
+ }
+ catch (HttpRequestException ex)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.FailedToReachCollector(this.Endpoint, ex);
+
+ return new ExportClientHttpResponse(success: false, deadlineUtc: deadlineUtc, response: null, exception: ex);
+ }
+ }
+
+ public bool Shutdown(int timeoutMilliseconds)
+ {
+ this.HttpClient.CancelPendingRequests();
+ return true;
+ }
+
+ public HttpRequestMessage CreateHttpRequest(byte[] exportRequest, int contentLength)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, this.Endpoint);
+ foreach (var header in this.Headers)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+
+ request.Content = new ByteArrayContent(exportRequest, 0, contentLength);
+ request.Content.Headers.ContentType = MediaHeaderValue;
+
+ return request;
+ }
+
+ protected HttpResponseMessage SendHttpRequest(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+#if NET6_0_OR_GREATER
+ return this.HttpClient.Send(request, cancellationToken);
+#else
+ return this.HttpClient.SendAsync(request, cancellationToken).GetAwaiter().GetResult();
+#endif
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/TrailingHeadersHelpers.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/TrailingHeadersHelpers.cs
new file mode 100644
index 00000000000..7536900fe6d
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/ExportClient/TrailingHeadersHelpers.cs
@@ -0,0 +1,64 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+// Includes work from:
+/*
+* Copyright 2019 The gRPC Authors
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using System.Net.Http.Headers;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+
+internal static class TrailingHeadersHelpers
+{
+ public static readonly string ResponseTrailersKey = "__ResponseTrailers";
+
+ public static HttpHeaders TrailingHeaders(this HttpResponseMessage responseMessage)
+ {
+#if !NETSTANDARD2_0 && !NET462
+ return responseMessage.TrailingHeaders;
+#else
+ if (responseMessage.RequestMessage.Properties.TryGetValue(ResponseTrailersKey, out var headers) &&
+ headers is HttpHeaders httpHeaders)
+ {
+ return httpHeaders;
+ }
+
+ // App targets .NET Standard 2.0 and the handler hasn't set trailers
+ // in RequestMessage.Properties with known key. Return empty collection.
+ // Client call will likely fail because it is unable to get a grpc-status.
+ return ResponseTrailers.Empty;
+#endif
+ }
+
+#if NETSTANDARD2_0 || NET462
+ public static void EnsureTrailingHeaders(this HttpResponseMessage responseMessage)
+ {
+ if (!responseMessage.RequestMessage.Properties.ContainsKey(ResponseTrailersKey))
+ {
+ responseMessage.RequestMessage.Properties[ResponseTrailersKey] = new ResponseTrailers();
+ }
+ }
+
+ private class ResponseTrailers : HttpHeaders
+ {
+ public static readonly ResponseTrailers Empty = new ResponseTrailers();
+ }
+#endif
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySerializer.cs
new file mode 100644
index 00000000000..bf4ad69be7f
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySerializer.cs
@@ -0,0 +1,428 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using OpenTelemetry.Internal;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal class ActivitySerializer
+{
+ private static readonly ConcurrentBag> ActivityListPool = new();
+
+ private readonly SdkLimitOptions sdkLimitOptions;
+
+ private readonly ActivitySizeCalculator activitySizeCalculator;
+
+ internal ActivitySerializer(SdkLimitOptions sdkLimitOptions)
+ {
+ this.sdkLimitOptions = sdkLimitOptions;
+ this.activitySizeCalculator = new ActivitySizeCalculator(sdkLimitOptions);
+ }
+
+ internal int Serialize(ref byte[] buffer, int offset, Resource? resource, Batch batch)
+ {
+ Dictionary> scopeTraces = new();
+ foreach (var activity in batch)
+ {
+ if (scopeTraces.TryGetValue(activity.Source.Name, out var activityList))
+ {
+ activityList.Add(activity);
+ }
+ else
+ {
+ if (!ActivityListPool.TryTake(out var newList))
+ {
+ newList = new List();
+ }
+
+ newList.Add(activity);
+ scopeTraces[activity.Source.Name] = newList;
+ }
+ }
+
+ var cursor = this.SerializeResourceSpans(ref buffer, offset, resource, scopeTraces);
+
+ this.ReturnActivityListToPool(scopeTraces);
+
+ return cursor;
+ }
+
+ internal void ReturnActivityListToPool(Dictionary>? scopeTraces)
+ {
+ if (scopeTraces != null)
+ {
+ foreach (var entry in scopeTraces)
+ {
+ entry.Value.Clear();
+ ActivityListPool.Add(entry.Value);
+ }
+ }
+ }
+
+ private static int SerializeTraceId(ref byte[] buffer, int cursor, ActivityTraceId activityTraceId)
+ {
+ // TODO: optimize alloc for buffer resizing scenario.
+ if (cursor + ActivitySizeCalculator.TraceIdSize <= buffer.Length)
+ {
+ var traceBytes = new Span(buffer, cursor, ActivitySizeCalculator.TraceIdSize);
+ activityTraceId.CopyTo(traceBytes);
+ cursor += ActivitySizeCalculator.TraceIdSize;
+
+ return cursor;
+ }
+ else
+ {
+ var traceIdBytes = new byte[ActivitySizeCalculator.TraceIdSize];
+ activityTraceId.CopyTo(traceIdBytes);
+
+ foreach (var b in traceIdBytes)
+ {
+ cursor = Writer.WriteSingleByte(ref buffer, cursor, b);
+ }
+
+ return cursor;
+ }
+ }
+
+ private static int SerializeSpanId(ref byte[] buffer, int cursor, ActivitySpanId activitySpanId)
+ {
+ // TODO: optimize alloc for buffer resizing scenario.
+ if (cursor + ActivitySizeCalculator.SpanIdSize <= buffer.Length)
+ {
+ var spanIdBytes = new Span(buffer, cursor, ActivitySizeCalculator.SpanIdSize);
+ activitySpanId.CopyTo(spanIdBytes);
+ cursor += ActivitySizeCalculator.SpanIdSize;
+
+ return cursor;
+ }
+ else
+ {
+ var spanIdBytes = new byte[ActivitySizeCalculator.SpanIdSize];
+ activitySpanId.CopyTo(spanIdBytes);
+
+ foreach (var b in spanIdBytes)
+ {
+ cursor = Writer.WriteSingleByte(ref buffer, cursor, b);
+ }
+
+ return cursor;
+ }
+ }
+
+ private static int SerializeTraceFlags(ref byte[] buffer, int cursor, ActivityTraceFlags activityTraceFlags, bool hasRemoteParent, int fieldNumber)
+ {
+ uint spanFlags = (uint)activityTraceFlags & (byte)0x000000FF;
+
+ spanFlags |= 0x00000100;
+ if (hasRemoteParent)
+ {
+ spanFlags |= 0x00000200;
+ }
+
+ cursor = Writer.WriteFixed32WithTag(ref buffer, cursor, fieldNumber, spanFlags);
+
+ return cursor;
+ }
+
+ private static int SerializeActivityStatus(ref byte[] buffer, int cursor, Activity activity, StatusCode? statusCode, string? statusMessage)
+ {
+ if (activity.Status == ActivityStatusCode.Unset && statusCode == null)
+ {
+ return cursor;
+ }
+
+ var statusSize = ActivitySizeCalculator.ComputeActivityStatusSize(activity, statusCode, statusMessage);
+
+ if (statusSize > 0)
+ {
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, statusSize, FieldNumberConstants.Span_status, WireType.LEN);
+ }
+
+ if (activity.Status != ActivityStatusCode.Unset)
+ {
+ cursor = Writer.WriteEnumWithTag(ref buffer, cursor, FieldNumberConstants.Status_code, (int)activity.Status);
+
+ if (activity.Status == ActivityStatusCode.Error && activity.StatusDescription != null)
+ {
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.Status_message, activity.StatusDescription);
+ }
+ }
+ else if (statusCode != StatusCode.Unset)
+ {
+ cursor = Writer.WriteEnumWithTag(ref buffer, cursor, FieldNumberConstants.Status_code, (int)statusCode!);
+
+ if (statusCode == StatusCode.Error && statusMessage != null)
+ {
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.Status_message, statusMessage);
+ }
+ }
+
+ return cursor;
+ }
+
+ // SerializeResourceSpans
+ private int SerializeResourceSpans(ref byte[] buffer, int cursor, Resource? resource, Dictionary> scopeTraces)
+ {
+ var start = cursor;
+
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+
+ var resourceSpansSize = this.activitySizeCalculator.ComputeResourceSpansSize(resource, scopeTraces);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, resourceSpansSize, FieldNumberConstants.ResourceSpans_resource, WireType.LEN);
+ cursor = CommonTypesSerializer.SerializeResource(ref buffer, cursor, resource, maxAttributeValueLength);
+ cursor = this.SerializeScopeSpans(ref buffer, cursor, scopeTraces);
+
+ return cursor;
+ }
+
+ private int SerializeScopeSpans(ref byte[] buffer, int cursor, Dictionary> scopeTraces)
+ {
+ if (scopeTraces != null)
+ {
+ foreach (KeyValuePair> entry in scopeTraces)
+ {
+ var scopeSize = this.activitySizeCalculator.ComputeScopeSpanSize(entry.Key, entry.Value[0].Source.Version, entry.Value);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, scopeSize, FieldNumberConstants.ResourceSpans_scope_spans, WireType.LEN);
+ cursor = this.SerializeSingleScopeSpan(ref buffer, cursor, entry.Key, entry.Value[0].Source.Version, entry.Value);
+ }
+ }
+
+ return cursor;
+ }
+
+ private int SerializeSingleScopeSpan(ref byte[] buffer, int cursor, string activitySourceName, string? activitySourceVersion, List activities)
+ {
+ var instrumentationScopeSize = CommonTypesSizeCalculator.ComputeInstrumentationScopeSize(activitySourceName, activitySourceVersion);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, instrumentationScopeSize, FieldNumberConstants.ScopeSpans_scope, WireType.LEN);
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.InstrumentationScope_name, activitySourceName);
+ if (activitySourceVersion != null)
+ {
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.InstrumentationScope_version, activitySourceVersion);
+ }
+
+ foreach (var activity in activities)
+ {
+ int spanSize = this.activitySizeCalculator.ComputeActivitySize(activity);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, spanSize, FieldNumberConstants.ScopeSpans_span, WireType.LEN);
+ cursor = this.SerializeActivity(ref buffer, cursor, activity);
+ }
+
+ return cursor;
+ }
+
+ private int SerializeActivity(ref byte[] buffer, int cursor, Activity activity)
+ {
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.Span_name, activity.DisplayName);
+ if (activity.TraceStateString != null)
+ {
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.Span_trace_state, activity.TraceStateString);
+ }
+
+ cursor = Writer.WriteEnumWithTag(ref buffer, cursor, FieldNumberConstants.Span_kind, (int)activity.Kind + 1);
+ cursor = Writer.WriteFixed64WithTag(ref buffer, cursor, FieldNumberConstants.Span_start_time_unix_nano, (ulong)activity.StartTimeUtc.ToUnixTimeNanoseconds());
+ cursor = Writer.WriteFixed64WithTag(ref buffer, cursor, FieldNumberConstants.Span_end_time_unix_nano, (ulong)(activity.StartTimeUtc.ToUnixTimeNanoseconds() + activity.Duration.ToNanoseconds()));
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, 16, FieldNumberConstants.Span_trace_id, WireType.LEN);
+ cursor = SerializeTraceId(ref buffer, cursor, activity.TraceId);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, 8, FieldNumberConstants.Span_span_id, WireType.LEN);
+ cursor = SerializeSpanId(ref buffer, cursor, activity.SpanId);
+ if (activity.ParentSpanId != default)
+ {
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, 8, FieldNumberConstants.Span_parent_span_id, WireType.LEN);
+ cursor = SerializeSpanId(ref buffer, cursor, activity.ParentSpanId);
+ }
+
+ cursor = this.SerializeActivityTags(ref buffer, cursor, activity, out var statusCode, out var statusMessage);
+ cursor = SerializeActivityStatus(ref buffer, cursor, activity, statusCode, statusMessage);
+ cursor = this.SerializeActivityEvents(ref buffer, cursor, activity);
+ cursor = this.SerializeActivityLinks(ref buffer, cursor, activity);
+ cursor = SerializeTraceFlags(ref buffer, cursor, activity.ActivityTraceFlags, activity.HasRemoteParent, FieldNumberConstants.Span_flags);
+ return cursor;
+ }
+
+ private int SerializeActivityTags(ref byte[] buffer, int cursor, Activity activity, out StatusCode? statusCode, out string? statusMessage)
+ {
+ statusCode = null;
+ statusMessage = null;
+ int maxAttributeCount = this.sdkLimitOptions.SpanAttributeCountLimit ?? int.MaxValue;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int attributeCount = 0;
+ int droppedAttributeCount = 0;
+ foreach (ref readonly var tag in activity.EnumerateTagObjects())
+ {
+ switch (tag.Key)
+ {
+ case SpanAttributeConstants.StatusCodeKey:
+ statusCode = StatusHelper.GetStatusCodeForTagValue(tag.Value as string);
+ continue;
+ case SpanAttributeConstants.StatusDescriptionKey:
+ statusMessage = tag.Value as string;
+ continue;
+ }
+
+ if (attributeCount < maxAttributeCount)
+ {
+ /*
+ // Alternate approach
+ // Reset the Cursor for tagwriter.
+ this.tagWriterState.Cursor = 0;
+ OtlpTagWriter.Instance.TryWriteTag(ref this.tagWriterState, tag, maxAttributeValueLength);
+
+ // Write tag and length prefix for keyValue.
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, this.tagWriterState.Cursor, FieldNumberConstants.Span_attributes, WireType.LEN);
+
+ // Increase buffer size if needed.
+ if (cursor + this.tagWriterState.Cursor >= buffer.Length)
+ {
+ Writer.RefreshBuffer(ref buffer);
+ }
+
+ // Copy the tagWriter buffer to main buffer.
+ Buffer.BlockCopy(this.tagWriterState.Buffer, 0, buffer, cursor, this.tagWriterState.Cursor);
+
+ // Move the main buffer cursor position.
+ cursor += this.tagWriterState.Cursor;
+ */
+ cursor = CommonTypesSerializer.SerializeKeyValuePair(ref buffer, cursor, FieldNumberConstants.Span_attributes, tag, maxAttributeValueLength);
+ attributeCount++;
+ }
+ else
+ {
+ droppedAttributeCount++;
+ }
+ }
+
+ if (droppedAttributeCount > 0)
+ {
+ cursor = Writer.WriteTag(ref buffer, cursor, FieldNumberConstants.Span_dropped_attributes_count, WireType.VARINT);
+ cursor = Writer.WriteVarint32(ref buffer, cursor, (uint)droppedAttributeCount);
+ }
+
+ return cursor;
+ }
+
+ private int SerializeActivityLinks(ref byte[] buffer, int cursor, Activity activity)
+ {
+ int maxLinksCount = this.sdkLimitOptions.SpanLinkCountLimit ?? int.MaxValue;
+ int linkCount = 0;
+ int droppedLinkCount = 0;
+
+ foreach (ref readonly var link in activity.EnumerateLinks())
+ {
+ if (linkCount < maxLinksCount)
+ {
+ var linkSize = this.activitySizeCalculator.ComputeActivityLinkSize(link);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, linkSize, FieldNumberConstants.Span_links, WireType.LEN);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, ActivitySizeCalculator.TraceIdSize, FieldNumberConstants.Link_trace_id, WireType.LEN);
+ cursor = SerializeTraceId(ref buffer, cursor, link.Context.TraceId);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, ActivitySizeCalculator.SpanIdSize, FieldNumberConstants.Link_span_id, WireType.LEN);
+ cursor = SerializeSpanId(ref buffer, cursor, link.Context.SpanId);
+ cursor = SerializeTraceFlags(ref buffer, cursor, link.Context.TraceFlags, link.Context.IsRemote, FieldNumberConstants.Link_flags);
+ cursor = this.SerializeLinkTags(ref buffer, cursor, link);
+ linkCount++;
+ }
+ else
+ {
+ droppedLinkCount++;
+ }
+ }
+
+ if (droppedLinkCount > 0)
+ {
+ cursor = Writer.WriteTag(ref buffer, cursor, FieldNumberConstants.Span_dropped_links_count, WireType.VARINT);
+ cursor = Writer.WriteVarint32(ref buffer, cursor, (uint)droppedLinkCount);
+ }
+
+ return cursor;
+ }
+
+ private int SerializeLinkTags(ref byte[] buffer, int cursor, ActivityLink link)
+ {
+ int maxAttributeCount = this.sdkLimitOptions.SpanLinkAttributeCountLimit ?? int.MaxValue;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int attributeCount = 0;
+ int droppedAttributeCount = 0;
+ foreach (ref readonly var tag in link.EnumerateTagObjects())
+ {
+ if (attributeCount < maxAttributeCount)
+ {
+ cursor = CommonTypesSerializer.SerializeKeyValuePair(ref buffer, cursor, FieldNumberConstants.Link_attributes, tag, maxAttributeValueLength);
+ attributeCount++;
+ }
+ else
+ {
+ droppedAttributeCount++;
+ }
+ }
+
+ if (droppedAttributeCount > 0)
+ {
+ cursor = Writer.WriteTag(ref buffer, cursor, FieldNumberConstants.Link_dropped_attributes_count, WireType.VARINT);
+ cursor = Writer.WriteVarint32(ref buffer, cursor, (uint)droppedAttributeCount);
+ }
+
+ return cursor;
+ }
+
+ private int SerializeActivityEvents(ref byte[] buffer, int cursor, Activity activity)
+ {
+ int maxEventCountLimit = this.sdkLimitOptions.SpanEventCountLimit ?? int.MaxValue;
+ int eventCount = 0;
+ int droppedEventCount = 0;
+ foreach (ref readonly var evnt in activity.EnumerateEvents())
+ {
+ if (eventCount < maxEventCountLimit)
+ {
+ int eventSize = this.activitySizeCalculator.ComputeActivityEventSize(evnt);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, eventSize, FieldNumberConstants.Span_events, WireType.LEN);
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.Event_name, evnt.Name);
+ cursor = Writer.WriteFixed64WithTag(ref buffer, cursor, FieldNumberConstants.Event_time_unix_nano, (ulong)evnt.Timestamp.ToUnixTimeNanoseconds());
+ cursor = this.SerializeEventTags(ref buffer, cursor, evnt);
+ eventCount++;
+ }
+ else
+ {
+ droppedEventCount++;
+ }
+ }
+
+ if (droppedEventCount > 0)
+ {
+ cursor = Writer.WriteTag(ref buffer, cursor, FieldNumberConstants.Span_dropped_events_count, WireType.VARINT);
+ cursor = Writer.WriteVarint32(ref buffer, cursor, (uint)droppedEventCount);
+ }
+
+ return cursor;
+ }
+
+ private int SerializeEventTags(ref byte[] buffer, int cursor, ActivityEvent evnt)
+ {
+ int maxAttributeCount = this.sdkLimitOptions.SpanEventAttributeCountLimit ?? int.MaxValue;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int attributeCount = 0;
+ int droppedAttributeCount = 0;
+ foreach (ref readonly var tag in evnt.EnumerateTagObjects())
+ {
+ if (attributeCount < maxAttributeCount)
+ {
+ cursor = CommonTypesSerializer.SerializeKeyValuePair(ref buffer, cursor, FieldNumberConstants.Event_attributes, tag, maxAttributeValueLength);
+ attributeCount++;
+ }
+ else
+ {
+ droppedAttributeCount++;
+ }
+ }
+
+ if (droppedAttributeCount > 0)
+ {
+ cursor = Writer.WriteTag(ref buffer, cursor, FieldNumberConstants.Event_dropped_attributes_count, WireType.VARINT);
+ cursor = Writer.WriteVarint32(ref buffer, cursor, (uint)droppedAttributeCount);
+ }
+
+ return cursor;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySizeCalculator.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySizeCalculator.cs
new file mode 100644
index 00000000000..34fcd0e52a3
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/ActivitySizeCalculator.cs
@@ -0,0 +1,311 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using OpenTelemetry.Internal;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal class ActivitySizeCalculator
+{
+ internal const int TraceIdSize = 16;
+ internal const int SpanIdSize = 8;
+ private const int KindSize = 1;
+ private const int TimeSize = 8;
+ private const int I32Size = 4;
+
+ private readonly SdkLimitOptions sdkLimitOptions;
+
+ internal ActivitySizeCalculator(SdkLimitOptions sdkLimitOptions)
+ {
+ this.sdkLimitOptions = sdkLimitOptions;
+ }
+
+ internal static int ComputeActivityStatusSize(Activity activity, StatusCode? statusCode, string? statusMessage)
+ {
+ int size = 0;
+ if (activity.Status == ActivityStatusCode.Unset && statusCode == null)
+ {
+ return size;
+ }
+
+ if (activity.Status != ActivityStatusCode.Unset)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Status_code);
+ size += 1;
+
+ if (activity.Status == ActivityStatusCode.Error && activity.StatusDescription != null)
+ {
+ size += CommonTypesSizeCalculator.ComputeStringWithTagSize(FieldNumberConstants.Status_message, activity.StatusDescription);
+ }
+ }
+ else if (statusCode != StatusCode.Unset)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Status_code);
+ size += 1;
+
+ if (statusCode == StatusCode.Error && statusMessage != null)
+ {
+ size += CommonTypesSizeCalculator.ComputeStringWithTagSize(FieldNumberConstants.Status_message, statusMessage);
+ }
+ }
+
+ return size;
+ }
+
+ internal int ComputeScopeSpanSize(string activitySourceName, string? activitySourceVersion, List scopeActivities)
+ {
+ int size = 0;
+ var instrumentationScopeSize = CommonTypesSizeCalculator.ComputeInstrumentationScopeSize(activitySourceName, activitySourceVersion);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.ScopeSpans_scope, instrumentationScopeSize);
+
+ foreach (var activity in scopeActivities)
+ {
+ var activitySize = this.ComputeActivitySize(activity);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.ScopeSpans_span, activitySize);
+ }
+
+ return size;
+ }
+
+ internal int ComputeActivitySize(Activity activity)
+ {
+ int size = 0;
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_trace_id, TraceIdSize);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_span_id, SpanIdSize);
+
+ if (activity.ParentSpanId != default)
+ {
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_parent_span_id, SpanIdSize);
+ }
+
+ size += CommonTypesSizeCalculator.ComputeStringWithTagSize(FieldNumberConstants.Span_name, activity.DisplayName);
+
+ if (activity.TraceStateString != null)
+ {
+ size += CommonTypesSizeCalculator.ComputeStringWithTagSize(FieldNumberConstants.Span_trace_state, activity.TraceStateString);
+ }
+
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_kind);
+ size += KindSize; // kind value
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_start_time_unix_nano);
+ size += TimeSize; // start time
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_end_time_unix_nano);
+ size += TimeSize; // end time
+
+ size += this.ComputeActivityAttributesSize(activity, out var droppedCount, out var statusCode, out var statusMessage);
+ if (droppedCount > 0)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_dropped_attributes_count);
+ size += WireTypesSizeCalculator.ComputeVarint32Size((uint)droppedCount);
+ }
+
+ var statusMessageSize = ComputeActivityStatusSize(activity, statusCode, statusMessage);
+ if (statusMessageSize > 0)
+ {
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_status, statusMessageSize);
+ }
+
+ size += this.ComputeActivityEventsSize(activity, out var droppedEventCount);
+ if (droppedEventCount > 0)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_dropped_events_count);
+ size += WireTypesSizeCalculator.ComputeVarint32Size((uint)droppedEventCount);
+ }
+
+ size += this.ComputeActivityLinksSize(activity, out var droppedLinkCount);
+ if (droppedLinkCount > 0)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_dropped_links_count);
+ size += WireTypesSizeCalculator.ComputeVarint32Size((uint)droppedLinkCount);
+ }
+
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Span_flags);
+ size += I32Size;
+
+ return size;
+ }
+
+ internal int ComputeActivityLinksSize(Activity activity, out int droppedLinkCount)
+ {
+ droppedLinkCount = 0;
+ int size = 0;
+ int maxLinksCount = this.sdkLimitOptions.SpanLinkCountLimit ?? int.MaxValue;
+ int linkCount = 0;
+ if (activity.Links != null)
+ {
+ foreach (ref readonly var link in activity.EnumerateLinks())
+ {
+ if (linkCount < maxLinksCount)
+ {
+ var linkSize = this.ComputeActivityLinkSize(link);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_links, linkSize);
+ linkCount++;
+ }
+ else
+ {
+ droppedLinkCount++;
+ }
+ }
+ }
+
+ return size;
+ }
+
+ internal int ComputeActivityLinkSize(ActivityLink link)
+ {
+ int size = 0;
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Link_trace_id);
+ size += WireTypesSizeCalculator.ComputeLengthSize(16);
+ size += TraceIdSize;
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Link_span_id);
+ size += WireTypesSizeCalculator.ComputeLengthSize(8);
+ size += SpanIdSize;
+
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Link_flags);
+ size += I32Size;
+
+ int droppedAttributeCount = 0;
+ int attributeCount = 0;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+
+ foreach (ref readonly var tag in link.EnumerateTagObjects())
+ {
+ if (attributeCount < this.sdkLimitOptions.SpanLinkAttributeCountLimit)
+ {
+ var keyValueSize = CommonTypesSizeCalculator.ComputeKeyValuePairSize(tag, maxAttributeValueLength);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Link_attributes, keyValueSize);
+ attributeCount++;
+ }
+ else
+ {
+ droppedAttributeCount++;
+ }
+ }
+
+ if (droppedAttributeCount > 0)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Link_dropped_attributes_count);
+ size += WireTypesSizeCalculator.ComputeLengthSize(droppedAttributeCount);
+ }
+
+ return size;
+ }
+
+ internal int ComputeActivityEventsSize(Activity activity, out int droppedEventCount)
+ {
+ droppedEventCount = 0;
+ int size = 0;
+ int maxEventCountLimit = this.sdkLimitOptions.SpanEventCountLimit ?? int.MaxValue;
+ int eventCount = 0;
+ if (activity.Events != null)
+ {
+ foreach (ref readonly var evnt in activity.EnumerateEvents())
+ {
+ if (eventCount < maxEventCountLimit)
+ {
+ var evntSize = this.ComputeActivityEventSize(evnt);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_events, evntSize);
+ eventCount++;
+ }
+ else
+ {
+ droppedEventCount++;
+ }
+ }
+ }
+
+ return size;
+ }
+
+ internal int ComputeActivityEventSize(ActivityEvent evnt)
+ {
+ int spanEventAttributeCountLimit = this.sdkLimitOptions.SpanEventAttributeCountLimit ?? int.MaxValue;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int droppedAttributeCount = 0;
+ int attributeCount = 0;
+ int size = 0;
+ size += CommonTypesSizeCalculator.ComputeStringWithTagSize(FieldNumberConstants.Event_name, evnt.Name);
+ size += TimeSize; // event time
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Event_time_unix_nano);
+ foreach (ref readonly var tag in evnt.EnumerateTagObjects())
+ {
+ if (attributeCount < spanEventAttributeCountLimit)
+ {
+ var keyValueSize = CommonTypesSizeCalculator.ComputeKeyValuePairSize(tag, maxAttributeValueLength);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Event_attributes, keyValueSize);
+ attributeCount++;
+ }
+ else
+ {
+ droppedAttributeCount++;
+ }
+ }
+
+ if (droppedAttributeCount > 0)
+ {
+ size += WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.Event_dropped_attributes_count);
+ size += WireTypesSizeCalculator.ComputeLengthSize(droppedAttributeCount);
+ }
+
+ return size;
+ }
+
+ internal int ComputeActivityAttributesSize(Activity activity, out int droppedCount, out StatusCode? statusCode, out string? statusMessage)
+ {
+ statusCode = null;
+ statusMessage = null;
+ int maxAttributeCount = this.sdkLimitOptions.SpanAttributeCountLimit ?? int.MaxValue;
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int size = 0;
+ int attributeCount = 0;
+ droppedCount = 0;
+ foreach (ref readonly var tag in activity.EnumerateTagObjects())
+ {
+ switch (tag.Key)
+ {
+ case SpanAttributeConstants.StatusCodeKey:
+ statusCode = StatusHelper.GetStatusCodeForTagValue(tag.Value as string);
+ continue;
+ case SpanAttributeConstants.StatusDescriptionKey:
+ statusMessage = tag.Value as string;
+ continue;
+ }
+
+ if (attributeCount < maxAttributeCount)
+ {
+ var keyValueSize = CommonTypesSizeCalculator.ComputeKeyValuePairSize(tag, maxAttributeValueLength);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Span_attributes, keyValueSize);
+ attributeCount++;
+ }
+ else
+ {
+ droppedCount++;
+ }
+ }
+
+ return size;
+ }
+
+ internal int ComputeResourceSpansSize(Resource? resource, Dictionary> scopeTraces)
+ {
+ int maxAttributeValueLength = this.sdkLimitOptions.AttributeValueLengthLimit ?? int.MaxValue;
+ int size = 0;
+ var resourceSize = CommonTypesSizeCalculator.ComputeResourceSize(resource, maxAttributeValueLength);
+
+ if (resourceSize > 0)
+ {
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.ResourceSpans_resource, resourceSize);
+ }
+
+ foreach (var scopeTrace in scopeTraces)
+ {
+ var scopeSpanSize = this.ComputeScopeSpanSize(scopeTrace.Key, scopeTrace.Value[0].Source.Version, scopeTrace.Value);
+ size += CommonTypesSizeCalculator.ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.ResourceSpans_scope_spans, scopeSpanSize);
+ }
+
+ return size;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSerializer.cs
new file mode 100644
index 00000000000..54d0cf1498b
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSerializer.cs
@@ -0,0 +1,102 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Globalization;
+using OpenTelemetry.Resources;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal static class CommonTypesSerializer
+{
+ internal static int SerializeResource(ref byte[] buffer, int cursor, Resource? resource, int maxAttributeValueLength)
+ {
+ if (resource != null && resource != Resource.Empty)
+ {
+ var resourceSize = CommonTypesSizeCalculator.ComputeResourceSize(resource, maxAttributeValueLength);
+ if (resourceSize > 0)
+ {
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, resourceSize, FieldNumberConstants.ResourceSpans_resource, WireType.LEN);
+ foreach (var attribute in resource.Attributes)
+ {
+ var tagSize = CommonTypesSizeCalculator.ComputeKeyValuePairSize(attribute!, maxAttributeValueLength);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, tagSize, FieldNumberConstants.Resource_attributes, WireType.LEN);
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.KeyValue_key, attribute.Key);
+ cursor = SerializeAnyValue(ref buffer, cursor, attribute.Value, FieldNumberConstants.KeyValue_value, maxAttributeValueLength);
+ }
+ }
+ }
+
+ return cursor;
+ }
+
+ internal static int SerializeKeyValuePair(ref byte[] buffer, int cursor, int fieldNumber, KeyValuePair tag, int maxAttributeValueLength)
+ {
+ var tagSize = CommonTypesSizeCalculator.ComputeKeyValuePairSize(tag, maxAttributeValueLength);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, tagSize, fieldNumber, WireType.LEN);
+ cursor = Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.KeyValue_key, tag.Key);
+ cursor = SerializeAnyValue(ref buffer, cursor, tag.Value, FieldNumberConstants.KeyValue_value, maxAttributeValueLength);
+
+ return cursor;
+ }
+
+ internal static int SerializeArray(ref byte[] buffer, int cursor, Array array, int maxAttributeValueLength)
+ {
+ var arraySize = CommonTypesSizeCalculator.ComputeArrayValueSize(array, maxAttributeValueLength);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, arraySize, FieldNumberConstants.AnyValue_array_value, WireType.LEN);
+ foreach (var ar in array)
+ {
+ cursor = SerializeAnyValue(ref buffer, cursor, ar, FieldNumberConstants.ArrayValue_Value, maxAttributeValueLength);
+ }
+
+ return cursor;
+ }
+
+ internal static int SerializeAnyValue(ref byte[] buffer, int cursor, object? value, int fieldNumber, int maxAttributeValueLength)
+ {
+ var anyValueSize = CommonTypesSizeCalculator.ComputeAnyValueSize(value, maxAttributeValueLength);
+ cursor = Writer.WriteTagAndLengthPrefix(ref buffer, cursor, anyValueSize, fieldNumber, WireType.LEN);
+ if (value == null)
+ {
+ return cursor;
+ }
+
+ switch (value)
+ {
+ case char:
+ case string:
+ var rawStringVal = Convert.ToString(value, CultureInfo.InvariantCulture);
+ var stringVal = rawStringVal;
+ if (rawStringVal?.Length > maxAttributeValueLength)
+ {
+ stringVal = rawStringVal.Substring(0, maxAttributeValueLength);
+ }
+
+ return Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.AnyValue_string_value, stringVal!);
+ case bool:
+ return Writer.WriteBoolWithTag(ref buffer, cursor, FieldNumberConstants.AnyValue_bool_value, (bool)value);
+ case byte:
+ case sbyte:
+ case short:
+ case ushort:
+ case int:
+ case uint:
+ case long:
+ case ulong:
+ return Writer.WriteInt64WithTag(ref buffer, cursor, FieldNumberConstants.AnyValue_int_value, (ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
+ case float:
+ case double:
+ return Writer.WriteDoubleWithTag(ref buffer, cursor, FieldNumberConstants.AnyValue_double_value, Convert.ToDouble(value, CultureInfo.InvariantCulture));
+ case Array array:
+ return SerializeArray(ref buffer, cursor, array, maxAttributeValueLength);
+ default:
+ var defaultRawStringVal = Convert.ToString(value, CultureInfo.InvariantCulture);
+ var defaultStringVal = defaultRawStringVal;
+ if (defaultRawStringVal?.Length > maxAttributeValueLength)
+ {
+ defaultStringVal = defaultRawStringVal.Substring(0, maxAttributeValueLength);
+ }
+
+ return Writer.WriteStringWithTag(ref buffer, cursor, FieldNumberConstants.AnyValue_string_value, defaultStringVal!);
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSizeCalculator.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSizeCalculator.cs
new file mode 100644
index 00000000000..3c8c7530f62
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/CommonTypesSizeCalculator.cs
@@ -0,0 +1,133 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Globalization;
+using System.Text;
+using OpenTelemetry.Resources;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal static class CommonTypesSizeCalculator
+{
+ internal static int ComputeStringWithTagSize(int fieldNumber, string value)
+ {
+ int size = 0;
+ size += WireTypesSizeCalculator.ComputeTagSize(fieldNumber);
+ var stringLength = Encoding.UTF8.GetByteCount(value);
+ size += WireTypesSizeCalculator.ComputeLengthSize(stringLength);
+ size += stringLength;
+
+ return size;
+ }
+
+ internal static int ComputeSizeWithTagAndLengthPrefix(int fieldNumber, int numberOfbytes)
+ {
+ int size = 0;
+ size += WireTypesSizeCalculator.ComputeTagSize(fieldNumber);
+ size += WireTypesSizeCalculator.ComputeLengthSize(numberOfbytes); // length prefix for key value pair.
+ size += numberOfbytes;
+
+ return size;
+ }
+
+ internal static int ComputeInstrumentationScopeSize(string scopeName, string? scopeVersion)
+ {
+ int size = 0;
+
+ size += ComputeStringWithTagSize(FieldNumberConstants.InstrumentationScope_name, scopeName);
+
+ if (scopeVersion != null)
+ {
+ size += ComputeStringWithTagSize(FieldNumberConstants.InstrumentationScope_version, scopeVersion);
+ }
+
+ return size;
+ }
+
+ internal static int ComputeKeyValuePairSize(KeyValuePair tag, int maxAttributeValueLength)
+ {
+ int size = 0;
+ size += ComputeStringWithTagSize(FieldNumberConstants.KeyValue_key, tag.Key);
+
+ var anyValueSize = ComputeAnyValueSize(tag.Value, maxAttributeValueLength);
+ size += ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.KeyValue_value, anyValueSize);
+
+ return size;
+ }
+
+ internal static int ComputeAnyValueSize(object? value, int maxAttributeValueLength)
+ {
+ if (value == null)
+ {
+ return 0;
+ }
+
+ switch (value)
+ {
+ case char:
+ return ComputeStringWithTagSize(FieldNumberConstants.AnyValue_string_value, Convert.ToString(value, CultureInfo.InvariantCulture)!);
+ case string:
+ var rawStringVal = Convert.ToString(value, CultureInfo.InvariantCulture);
+ var stringVal = rawStringVal;
+ if (rawStringVal?.Length > maxAttributeValueLength)
+ {
+ stringVal = rawStringVal.Substring(0, maxAttributeValueLength);
+ }
+
+ return ComputeStringWithTagSize(FieldNumberConstants.AnyValue_string_value, stringVal!);
+ case bool:
+ return 1 + WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.AnyValue_bool_value);
+ case byte:
+ case sbyte:
+ case short:
+ case ushort:
+ case int:
+ case uint:
+ case long:
+ case ulong:
+ return WireTypesSizeCalculator.ComputeVarint64Size((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture)) + WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.AnyValue_int_value);
+ case float:
+ case double:
+ return 8 + WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.AnyValue_double_value);
+ case Array array:
+ var arraySize = ComputeArrayValueSize(array, maxAttributeValueLength);
+ return WireTypesSizeCalculator.ComputeTagSize(FieldNumberConstants.AnyValue_array_value) + WireTypesSizeCalculator.ComputeLengthSize(arraySize) + arraySize;
+ default:
+ var defaultRawStringVal = Convert.ToString(value, CultureInfo.InvariantCulture);
+ var defaultStringVal = defaultRawStringVal;
+ if (defaultRawStringVal?.Length > maxAttributeValueLength)
+ {
+ defaultStringVal = defaultRawStringVal.Substring(0, maxAttributeValueLength);
+ }
+
+ return ComputeStringWithTagSize(FieldNumberConstants.AnyValue_string_value, defaultStringVal!);
+ }
+ }
+
+ internal static int ComputeArrayValueSize(Array array, int maxAttributeValueLength)
+ {
+ int size = 0;
+ foreach (var value in array)
+ {
+ var anyValueSize = ComputeAnyValueSize(value, maxAttributeValueLength);
+ size += ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.ArrayValue_Value, anyValueSize);
+ }
+
+ return size;
+ }
+
+ internal static int ComputeResourceSize(Resource? resource, int maxAttributeValueLength)
+ {
+ int size = 0;
+ if (resource != null && resource != Resource.Empty)
+ {
+ foreach (var attribute in resource.Attributes)
+ {
+ var keyValueSize = ComputeKeyValuePairSize(attribute!, maxAttributeValueLength);
+ size += ComputeSizeWithTagAndLengthPrefix(FieldNumberConstants.Resource_attributes, keyValueSize);
+ }
+ }
+
+ return size;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/FieldNumberConstants.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/FieldNumberConstants.cs
new file mode 100644
index 00000000000..a48151a182a
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/FieldNumberConstants.cs
@@ -0,0 +1,90 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal class FieldNumberConstants
+{
+ // Resource spans
+#pragma warning disable SA1310 // Field names should not contain underscore
+ internal const int ResourceSpans_resource = 1;
+ internal const int ResourceSpans_scope_spans = 2;
+ internal const int ResourceSpans_schema_url = 3;
+
+ // Resource
+ internal const int Resource_attributes = 1;
+
+ // ScopeSpans
+ internal const int ScopeSpans_scope = 1;
+ internal const int ScopeSpans_span = 2;
+ internal const int ScopeSpans_shema_url = 3;
+
+ // Span
+ internal const int Span_trace_id = 1;
+ internal const int Span_span_id = 2;
+ internal const int Span_trace_state = 3;
+ internal const int Span_parent_span_id = 4;
+ internal const int Span_name = 5;
+ internal const int Span_kind = 6;
+ internal const int Span_start_time_unix_nano = 7;
+ internal const int Span_end_time_unix_nano = 8;
+ internal const int Span_attributes = 9;
+ internal const int Span_dropped_attributes_count = 10;
+ internal const int Span_events = 11;
+ internal const int Span_dropped_events_count = 12;
+ internal const int Span_links = 13;
+ internal const int Span_dropped_links_count = 14;
+ internal const int Span_status = 15;
+ internal const int Span_flags = 16;
+
+ // SpanKind
+ internal const int SpanKind_internal = 2;
+ internal const int SpanKind_server = 3;
+ internal const int SpanKind_client = 4;
+ internal const int SpanKind_producer = 5;
+ internal const int SpanKind_consumer = 6;
+
+ // Events
+ internal const int Event_time_unix_nano = 1;
+ internal const int Event_name = 2;
+ internal const int Event_attributes = 3;
+ internal const int Event_dropped_attributes_count = 4;
+
+ // Links
+ internal const int Link_trace_id = 1;
+ internal const int Link_span_id = 2;
+ internal const int Link_trace_state = 3;
+ internal const int Link_attributes = 4;
+ internal const int Link_dropped_attributes_count = 5;
+ internal const int Link_flags = 6;
+
+ // Status
+ internal const int Status_message = 2;
+ internal const int Status_code = 3;
+
+ // StatusCode
+ internal const int StatusCode_unset = 0;
+ internal const int StatusCode_ok = 1;
+ internal const int StatusCode_error = 2;
+
+ // InstrumentationScope
+ internal const int InstrumentationScope_name = 1;
+ internal const int InstrumentationScope_version = 2;
+
+ // KeyValue
+ internal const int KeyValue_key = 1;
+ internal const int KeyValue_value = 2;
+
+ // AnyValue
+ internal const int AnyValue_string_value = 1;
+ internal const int AnyValue_bool_value = 2;
+ internal const int AnyValue_int_value = 3;
+ internal const int AnyValue_double_value = 4;
+ internal const int AnyValue_array_value = 5;
+ internal const int AnyValue_kvlist_value = 6;
+ internal const int AnyValue_bytes_value = 7;
+
+ internal const int ArrayValue_Value = 1;
+#pragma warning restore SA1310 // Field names should not contain underscore
+}
+
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/OtlpTagWriter.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/OtlpTagWriter.cs
new file mode 100644
index 00000000000..ba112777002
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/OtlpTagWriter.cs
@@ -0,0 +1,140 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Text;
+using OpenTelemetry.Internal;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal sealed class OtlpTagWriter : TagWriter
+{
+ private OtlpTagWriter()
+ : base(new OtlpArrayTagWriter())
+ {
+ }
+
+ public static OtlpTagWriter Instance { get; } = new();
+
+ protected override void WriteIntegralTag(ref OtlpTagWriterState state, string key, long value)
+ {
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.KeyValue_key, key);
+ state.Cursor = Writer.WriteTagAndLengthPrefix(ref state.Buffer, state.Cursor, 9, FieldNumberConstants.KeyValue_value, WireType.LEN);
+ state.Cursor = Writer.WriteInt64WithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.AnyValue_int_value, (ulong)value);
+ }
+
+ protected override void WriteFloatingPointTag(ref OtlpTagWriterState state, string key, double value)
+ {
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.KeyValue_key, key);
+ state.Cursor = Writer.WriteTagAndLengthPrefix(ref state.Buffer, state.Cursor, 9, FieldNumberConstants.KeyValue_value, WireType.LEN);
+ state.Cursor = Writer.WriteDoubleWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.AnyValue_double_value, value);
+ }
+
+ protected override void WriteBooleanTag(ref OtlpTagWriterState state, string key, bool value)
+ {
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.KeyValue_key, key);
+ state.Cursor = Writer.WriteTagAndLengthPrefix(ref state.Buffer, state.Cursor, 2, FieldNumberConstants.KeyValue_value, WireType.LEN);
+ state.Cursor = Writer.WriteBoolWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.AnyValue_bool_value, value);
+ }
+
+ protected override void WriteStringTag(ref OtlpTagWriterState state, string key, ReadOnlySpan value)
+ {
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.KeyValue_key, key);
+
+#if NETFRAMEWORK || NETSTANDARD2_0
+ int numberOfUtf8CharsInString;
+ unsafe
+ {
+ fixed (char* p = value)
+ {
+ numberOfUtf8CharsInString = Encoding.UTF8.GetByteCount(p, value.Length);
+ }
+ }
+#else
+ int numberOfUtf8CharsInString = Encoding.UTF8.GetByteCount(value);
+#endif
+ state.Cursor = Writer.WriteTagAndLengthPrefix(ref state.Buffer, state.Cursor, numberOfUtf8CharsInString + 2, FieldNumberConstants.KeyValue_value, WireType.LEN);
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.AnyValue_string_value, value, numberOfUtf8CharsInString);
+ }
+
+ protected override void WriteArrayTag(ref OtlpTagWriterState state, string key, ref OtlpTagWriterArrayState value)
+ {
+ state.Cursor = Writer.WriteStringWithTag(ref state.Buffer, state.Cursor, FieldNumberConstants.KeyValue_key, key);
+
+ // Write Array tag and length
+ state.Cursor = Writer.WriteTagAndLengthPrefix(ref state.Buffer, state.Cursor, value.Cursor, FieldNumberConstants.AnyValue_array_value, WireType.LEN);
+
+ // Copy array bytes to tags buffer.
+ // TODO: handle insufficient space.
+ Buffer.BlockCopy(value.Buffer, 0, state.Buffer, state.Cursor, value.Cursor);
+
+ // Move the cursor for tags.
+ state.Cursor += value.Cursor;
+ }
+
+ protected override void OnUnsupportedTagDropped(
+ string tagKey,
+ string tagValueTypeFullName)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.UnsupportedAttributeType(
+ tagValueTypeFullName,
+ tagKey);
+ }
+
+ internal struct OtlpTagWriterState
+ {
+ public byte[] Buffer;
+ public int Cursor;
+ }
+
+ internal struct OtlpTagWriterArrayState
+ {
+ public byte[] Buffer;
+ public int Cursor;
+ }
+
+ private sealed class OtlpArrayTagWriter : ArrayTagWriter
+ {
+ [ThreadStatic]
+ private static byte[]? threadBuffer;
+
+ public override OtlpTagWriterArrayState BeginWriteArray()
+ {
+ threadBuffer ??= new byte[2048];
+
+ return new OtlpTagWriterArrayState
+ {
+ Buffer = threadBuffer,
+ Cursor = 0,
+ };
+ }
+
+ public override void WriteNullValue(ref OtlpTagWriterArrayState state)
+ {
+ // Do nothing.
+ }
+
+ public override void WriteIntegralValue(ref OtlpTagWriterArrayState state, long value)
+ {
+ // TODO
+ }
+
+ public override void WriteFloatingPointValue(ref OtlpTagWriterArrayState state, double value)
+ {
+ // TODO
+ }
+
+ public override void WriteBooleanValue(ref OtlpTagWriterArrayState state, bool value)
+ {
+ // TODO
+ }
+
+ public override void WriteStringValue(ref OtlpTagWriterArrayState state, ReadOnlySpan value)
+ {
+ // TODO
+ }
+
+ public override void EndWriteArray(ref OtlpTagWriterArrayState state)
+ {
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireType.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireType.cs
new file mode 100644
index 00000000000..77c3afbf5b0
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireType.cs
@@ -0,0 +1,47 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+///
+/// Wire types within protobuf encoding.
+/// https://protobuf.dev/programming-guides/encoding/#structure.
+///
+internal enum WireType : uint
+{
+ ///
+ /// Variable-length integer.
+ /// Used for int32, int64, uint32, uint64, sint32, sint64, bool, enum.
+ ///
+ VARINT = 0,
+
+ ///
+ /// A fixed-length 64-bit value.
+ /// Used for fixed64, sfixed64, double.
+ ///
+ I64 = 1,
+
+ ///
+ /// A length-delimited value.
+ /// Used for string, bytes, embedded messages, packed repeated fields.
+ ///
+ LEN = 2,
+
+ ///
+ /// Group Start value.
+ /// (Deperecated).
+ ///
+ SGROUP = 3,
+
+ ///
+ /// Group End value.
+ /// (Deprecated).
+ ///
+ EGROUP = 4,
+
+ ///
+ /// A fixed-length 32-bit value.
+ /// Used for fixed32, sfixed32, float.
+ ///
+ I32 = 5,
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireTypesSizeCalculator.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireTypesSizeCalculator.cs
new file mode 100644
index 00000000000..70a858e53bb
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/WireTypesSizeCalculator.cs
@@ -0,0 +1,97 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal class WireTypesSizeCalculator
+{
+ public static int ComputeTagSize(int fieldNumber)
+ {
+ return ComputeVarint32Size(MakeTag(fieldNumber, 0));
+ }
+
+ public static uint MakeTag(int fieldNumber, WireType wireType)
+ {
+ return (uint)(fieldNumber << 3) | (uint)wireType;
+ }
+
+ public static int ComputeLengthSize(int length)
+ {
+ return ComputeVarint32Size((uint)length);
+ }
+
+ public static int ComputeVarint32Size(uint value)
+ {
+ if ((value & (0xffffffff << 7)) == 0)
+ {
+ return 1;
+ }
+
+ if ((value & (0xffffffff << 14)) == 0)
+ {
+ return 2;
+ }
+
+ if ((value & (0xffffffff << 21)) == 0)
+ {
+ return 3;
+ }
+
+ if ((value & (0xffffffff << 28)) == 0)
+ {
+ return 4;
+ }
+
+ return 5;
+ }
+
+ public static int ComputeVarint64Size(ulong value)
+ {
+ if ((value & (0xffffffffffffffffL << 7)) == 0)
+ {
+ return 1;
+ }
+
+ if ((value & (0xffffffffffffffffL << 14)) == 0)
+ {
+ return 2;
+ }
+
+ if ((value & (0xffffffffffffffffL << 21)) == 0)
+ {
+ return 3;
+ }
+
+ if ((value & (0xffffffffffffffffL << 28)) == 0)
+ {
+ return 4;
+ }
+
+ if ((value & (0xffffffffffffffffL << 35)) == 0)
+ {
+ return 5;
+ }
+
+ if ((value & (0xffffffffffffffffL << 42)) == 0)
+ {
+ return 6;
+ }
+
+ if ((value & (0xffffffffffffffffL << 49)) == 0)
+ {
+ return 7;
+ }
+
+ if ((value & (0xffffffffffffffffL << 56)) == 0)
+ {
+ return 8;
+ }
+
+ if ((value & (0xffffffffffffffffL << 63)) == 0)
+ {
+ return 9;
+ }
+
+ return 10;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/Writer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/Writer.cs
new file mode 100644
index 00000000000..51be2d2dad7
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Serializer/Writer.cs
@@ -0,0 +1,332 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Buffers.Binary;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+
+internal class Writer
+{
+ private const uint Uint128 = 128;
+ private const ulong Ulong128 = 128;
+ private const int Fixed64Size = 8;
+
+ internal static Encoding Utf8Encoding => Encoding.UTF8;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteStringWithTag(ref byte[] buffer, int cursor, int fieldNumber, string value)
+ {
+ return WriteStringWithTag(ref buffer, cursor, fieldNumber, value.AsSpan());
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteStringWithTag(
+ ref byte[] buffer,
+ int cursor,
+ int fieldNumber,
+ ReadOnlySpan value,
+ int numberOfUtf8CharsInString = -1)
+ {
+ if (numberOfUtf8CharsInString < 0)
+ {
+#if NETFRAMEWORK || NETSTANDARD2_0
+ unsafe
+ {
+ fixed (char* strPtr = value)
+ {
+ numberOfUtf8CharsInString = Encoding.UTF8.GetByteCount(strPtr, value.Length);
+ }
+ }
+#else
+ numberOfUtf8CharsInString = Encoding.UTF8.GetByteCount(value);
+#endif
+ }
+
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.LEN);
+
+ cursor = WriteLength(ref buffer, cursor, numberOfUtf8CharsInString);
+
+ while (cursor + numberOfUtf8CharsInString > buffer.Length)
+ {
+ GrowBuffer(ref buffer);
+ }
+
+#if NETFRAMEWORK || NETSTANDARD2_0
+ unsafe
+ {
+ fixed (char* strPtr = value)
+ {
+ fixed (byte* bufferPtr = buffer)
+ {
+ Encoding.UTF8.GetBytes(strPtr, value.Length, bufferPtr + cursor, numberOfUtf8CharsInString);
+ }
+ }
+ }
+#else
+ _ = Encoding.UTF8.GetBytes(value, buffer.AsSpan().Slice(cursor));
+#endif
+
+ cursor += numberOfUtf8CharsInString;
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteTagAndLengthPrefix(ref byte[] buffer, int cursor, int contentLength, int fieldNumber, WireType type)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, type);
+ cursor = WriteLength(ref buffer, cursor, contentLength);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteEnumWithTag(ref byte[] buffer, int cursor, int fieldNumber, int value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.VARINT);
+
+ // Assuming 1 byte which matches the intended use.
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteTag(ref byte[] buffer, int cursor, int fieldNumber, WireType type)
+ {
+ cursor = WriteVarint32(ref buffer, cursor, GetTagValue(fieldNumber, type));
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteLength(ref byte[] buffer, int cursor, int length)
+ {
+ cursor = WriteVarint32(ref buffer, cursor, (uint)length);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteFixed64WithTag(ref byte[] buffer, int cursor, int fieldNumber, ulong value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.I64);
+ cursor = WriteFixed64LittleEndianFormat(ref buffer, cursor, value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteFixed32WithTag(ref byte[] buffer, int cursor, int fieldNumber, uint value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.I32);
+ cursor = WriteFixed32LittleEndianFormat(ref buffer, cursor, value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteBoolWithTag(ref byte[] buffer, int cursor, int fieldNumber, bool value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.VARINT);
+ cursor = WriteSingleByte(ref buffer, cursor, value ? (byte)1 : (byte)0);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteVarint32(ref byte[] buffer, int cursor, uint value)
+ {
+ while (cursor < buffer.Length && value >= Uint128)
+ {
+ buffer[cursor++] = (byte)(0x80 | (value & 0x7F));
+ value >>= 7;
+ }
+
+ if (cursor < buffer.Length)
+ {
+ buffer[cursor++] = (byte)value;
+ return cursor;
+ }
+
+ // Handle case of insufficient buffer space.
+ while (value >= Uint128)
+ {
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)((value & 0x7F) | 0x80));
+ value >>= 7;
+ }
+
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteVarint64(ref byte[] buffer, int cursor, ulong value)
+ {
+ while (cursor < buffer.Length && value >= Ulong128)
+ {
+ buffer[cursor++] = (byte)(0x80 | (value & 0x7F));
+ value >>= 7;
+ }
+
+ if (cursor < buffer.Length)
+ {
+ buffer[cursor++] = (byte)value;
+ return cursor;
+ }
+
+ // Handle case of insufficient buffer space.
+ while (value >= Ulong128)
+ {
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)((value & 0x7F) | 0x80));
+ value >>= 7;
+ }
+
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteInt64WithTag(ref byte[] buffer, int cursor, int fieldNumber, ulong value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.VARINT);
+ cursor = WriteVarint64(ref buffer, cursor, value);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteDoubleWithTag(ref byte[] buffer, int cursor, int fieldNumber, double value)
+ {
+ cursor = WriteTag(ref buffer, cursor, fieldNumber, WireType.I64);
+ cursor = WriteFixed64LittleEndianFormat(ref buffer, cursor, (ulong)BitConverter.DoubleToInt64Bits(value));
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteFixed64LittleEndianFormat(ref byte[] buffer, int cursor, ulong value)
+ {
+ if (cursor + Fixed64Size <= buffer.Length)
+ {
+ Span span = new Span(buffer, cursor, Fixed64Size);
+
+ BinaryPrimitives.WriteUInt64LittleEndian(span, value);
+
+ cursor += Fixed64Size;
+ }
+ else
+ {
+ // Write byte by byte.
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)value);
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 8));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 16));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 24));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 32));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 40));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 48));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 56));
+ }
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteFixed32LittleEndianFormat(ref byte[] buffer, int cursor, uint value)
+ {
+ if (cursor + 4 <= buffer.Length)
+ {
+ Span span = new Span(buffer, cursor, 4);
+
+ BinaryPrimitives.WriteUInt32LittleEndian(span, value);
+
+ cursor += 4;
+ }
+ else
+ {
+ // Write byte by byte.
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)value);
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 8));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 16));
+ cursor = WriteSingleByte(ref buffer, cursor, (byte)(value >> 24));
+ }
+
+ return cursor;
+ }
+
+ internal static uint GetTagValue(int fieldNumber, WireType wireType)
+ {
+ return ((uint)(fieldNumber << 3)) | (uint)wireType;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteLengthCustom(ref byte[] buffer, int cursor, int length)
+ {
+ cursor = WriteVarintCustom(ref buffer, cursor, (uint)length);
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteVarintCustom(ref byte[] buffer, int cursor, uint value)
+ {
+ int index = 0;
+
+ // Loop until all 7 bits from the integer value have been encoded
+ while (value > 0)
+ {
+ byte chunk = (byte)(value & 0x7F); // Extract the least significant 7 bits
+ value >>= 7; // Right shift the value by 7 bits to process the next chunk
+
+ // If there are more bits to encode, set the most significant bit to 1
+ if (index < 3)
+ {
+ chunk |= 0x80;
+ }
+
+ buffer[cursor++] = chunk; // Store the encoded chunk
+ index++;
+ }
+
+ // If fewer than 3 bytes were used, pad with zeros
+ while (index < 2)
+ {
+ buffer[cursor++] = 0x80;
+ index++;
+ }
+
+ while (index < 3)
+ {
+ buffer[cursor++] = 0x00;
+ index++;
+ }
+
+ return cursor;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int WriteSingleByte(ref byte[] buffer, int cursor, byte value)
+ {
+ if (buffer.Length == cursor)
+ {
+ GrowBuffer(ref buffer);
+ }
+
+ buffer[cursor++] = value;
+
+ return cursor;
+ }
+
+ internal static void GrowBuffer(ref byte[] buffer)
+ {
+ var newBuffer = new byte[buffer.Length * 2];
+
+ Buffer.BlockCopy(buffer, 0, newBuffer, 0, buffer.Length);
+
+ buffer = newBuffer;
+ }
+}
+
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterPersistentStorageTransmissionHandler.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterPersistentStorageTransmissionHandler.cs
new file mode 100644
index 00000000000..c30ff1b1a51
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterPersistentStorageTransmissionHandler.cs
@@ -0,0 +1,170 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+using OpenTelemetry.PersistentStorage.Abstractions;
+using OpenTelemetry.PersistentStorage.FileSystem;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+
+internal sealed class OtlpExporterPersistentStorageTransmissionHandler : OtlpExporterTransmissionHandler, IDisposable
+{
+ private const int RetryIntervalInMilliseconds = 60000;
+ private readonly ManualResetEvent shutdownEvent = new(false);
+ private readonly ManualResetEvent dataExportNotification = new(false);
+ private readonly AutoResetEvent exportEvent = new(false);
+ private readonly Thread thread;
+ private readonly PersistentBlobProvider persistentBlobProvider;
+ private bool disposed;
+
+ public OtlpExporterPersistentStorageTransmissionHandler(IExportClient exportClient, double timeoutMilliseconds, string storagePath)
+ : this(new FileBlobProvider(storagePath), exportClient, timeoutMilliseconds)
+ {
+ }
+
+ internal OtlpExporterPersistentStorageTransmissionHandler(PersistentBlobProvider persistentBlobProvider, IExportClient exportClient, double timeoutMilliseconds)
+ : base(exportClient, timeoutMilliseconds)
+ {
+ Debug.Assert(persistentBlobProvider != null, "persistentBlobProvider was null");
+
+ this.persistentBlobProvider = persistentBlobProvider!;
+
+ this.thread = new Thread(this.RetryStoredRequests)
+ {
+ Name = $"OtlpExporter Persistent Retry Storage",
+ IsBackground = true,
+ };
+
+ this.thread.Start();
+ }
+
+ // Used for test.
+ internal bool InitiateAndWaitForRetryProcess(int timeOutMilliseconds)
+ {
+ this.exportEvent.Set();
+
+ return this.dataExportNotification.WaitOne(timeOutMilliseconds);
+ }
+
+ protected override bool OnSubmitRequestFailure(byte[] request, int contentLength, ExportClientResponse response)
+ {
+ if (RetryHelper.ShouldRetryRequest(response, OtlpRetry.InitialBackoffMilliseconds, out _))
+ {
+ var payloadLength = contentLength;
+ var offSet = 0;
+ if (response is ExportClientGrpcResponse)
+ {
+ payloadLength -= 5;
+ offSet = 5;
+ }
+
+ byte[]? data = new byte[payloadLength];
+
+ Buffer.BlockCopy(request, offSet, data, 0, payloadLength);
+
+ if (data != null)
+ {
+ return this.persistentBlobProvider.TryCreateBlob(data, out _);
+ }
+ }
+
+ return false;
+ }
+
+ protected override void OnShutdown(int timeoutMilliseconds)
+ {
+ var sw = timeoutMilliseconds == Timeout.Infinite ? null : Stopwatch.StartNew();
+
+ try
+ {
+ this.shutdownEvent.Set();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Dispose was called before shutdown.
+ }
+
+ this.thread.Join(timeoutMilliseconds);
+
+ if (sw != null)
+ {
+ var timeout = timeoutMilliseconds - sw.ElapsedMilliseconds;
+
+ base.OnShutdown((int)Math.Max(timeout, 0));
+ }
+ else
+ {
+ base.OnShutdown(timeoutMilliseconds);
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (!this.disposed)
+ {
+ if (disposing)
+ {
+ this.shutdownEvent.Dispose();
+ this.exportEvent.Dispose();
+ this.dataExportNotification.Dispose();
+ (this.persistentBlobProvider as IDisposable)?.Dispose();
+ }
+
+ this.disposed = true;
+ }
+ }
+
+ private void RetryStoredRequests()
+ {
+ var handles = new WaitHandle[] { this.shutdownEvent, this.exportEvent };
+ while (true)
+ {
+ try
+ {
+ var index = WaitHandle.WaitAny(handles, RetryIntervalInMilliseconds);
+ if (index == 0)
+ {
+ // Shutdown signaled
+ break;
+ }
+
+ int fileCount = 0;
+
+ // TODO: Run maintenance job.
+ // Transmit 10 files at a time.
+ while (fileCount < 10 && !this.shutdownEvent.WaitOne(0))
+ {
+ if (!this.persistentBlobProvider.TryGetBlob(out var blob))
+ {
+ break;
+ }
+
+ if (blob.TryLease((int)this.TimeoutMilliseconds) && blob.TryRead(out var data))
+ {
+ var deadlineUtc = DateTime.UtcNow.AddMilliseconds(this.TimeoutMilliseconds);
+ if (this.TryRetryRequest(data, data.Length, deadlineUtc, out var response) || !RetryHelper.ShouldRetryRequest(response, OtlpRetry.InitialBackoffMilliseconds, out var retryInfo))
+ {
+ blob.TryDelete();
+ }
+
+ // TODO: extend the lease period based on the response from server on retryAfter.
+ }
+
+ fileCount++;
+ }
+
+ // Set and reset the handle to notify export and wait for next signal.
+ // This is used for InitiateAndWaitForRetryProcess.
+ this.dataExportNotification.Set();
+ this.dataExportNotification.Reset();
+ }
+ catch (Exception ex)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.RetryStoredRequestException(ex);
+ return;
+ }
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterRetryTransmissionHandler.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterRetryTransmissionHandler.cs
new file mode 100644
index 00000000000..c364449cff9
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterRetryTransmissionHandler.cs
@@ -0,0 +1,36 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+
+internal sealed class OtlpExporterRetryTransmissionHandler : OtlpExporterTransmissionHandler
+{
+ internal OtlpExporterRetryTransmissionHandler(IExportClient exportClient, double timeoutMilliseconds)
+ : base(exportClient, timeoutMilliseconds)
+ {
+ }
+
+ protected override bool OnSubmitRequestFailure(byte[] request, int contentLength, ExportClientResponse response)
+ {
+ var nextRetryDelayMilliseconds = OtlpRetry.InitialBackoffMilliseconds;
+ while (RetryHelper.ShouldRetryRequest(response, nextRetryDelayMilliseconds, out var retryResult))
+ {
+ // Note: This delay cannot exceed the configured timeout period for otlp exporter.
+ // If the backend responds with `RetryAfter` duration that would result in exceeding the configured timeout period
+ // we would fail fast and drop the data.
+ Thread.Sleep(retryResult.RetryDelay);
+
+ if (this.TryRetryRequest(request, contentLength, response.DeadlineUtc, out response))
+ {
+ return true;
+ }
+
+ nextRetryDelayMilliseconds = retryResult.NextRetryDelayMilliseconds;
+ }
+
+ return false;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterTransmissionHandler.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterTransmissionHandler.cs
new file mode 100644
index 00000000000..02c2172e986
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpExporterTransmissionHandler.cs
@@ -0,0 +1,146 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+using OpenTelemetry.Internal;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+
+internal class OtlpExporterTransmissionHandler : IDisposable
+{
+ public OtlpExporterTransmissionHandler(IExportClient exportClient, double timeoutMilliseconds)
+ {
+ Guard.ThrowIfNull(exportClient);
+
+ this.ExportClient = exportClient;
+ this.TimeoutMilliseconds = timeoutMilliseconds;
+ }
+
+ internal IExportClient ExportClient { get; }
+
+ internal double TimeoutMilliseconds { get; }
+
+ ///
+ /// Attempts to send an export request to the server.
+ ///
+ /// The request to send to the server.
+ /// length of content.
+ /// if the request is sent successfully; otherwise, .
+ ///
+ public bool TrySubmitRequest(byte[] request, int contentLength)
+ {
+ try
+ {
+ var deadlineUtc = DateTime.UtcNow.AddMilliseconds(this.TimeoutMilliseconds);
+ var response = this.ExportClient.SendExportRequest(request, contentLength, deadlineUtc);
+ if (response.Success)
+ {
+ return true;
+ }
+
+ return this.OnSubmitRequestFailure(request, contentLength, response);
+ }
+ catch (Exception ex)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.TrySubmitRequestException(ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to shutdown the transmission handler, blocks the current thread
+ /// until shutdown completed or timed out.
+ ///
+ ///
+ /// The number (non-negative) of milliseconds to wait, or
+ /// Timeout.Infinite to wait indefinitely.
+ ///
+ ///
+ /// Returns if shutdown succeeded; otherwise, .
+ ///
+ public bool Shutdown(int timeoutMilliseconds)
+ {
+ Guard.ThrowIfInvalidTimeout(timeoutMilliseconds);
+
+ var sw = timeoutMilliseconds == Timeout.Infinite ? null : Stopwatch.StartNew();
+
+ this.OnShutdown(timeoutMilliseconds);
+
+ if (sw != null)
+ {
+ var timeout = timeoutMilliseconds - sw.ElapsedMilliseconds;
+
+ return this.ExportClient.Shutdown((int)Math.Max(timeout, 0));
+ }
+
+ return this.ExportClient.Shutdown(timeoutMilliseconds);
+ }
+
+ ///
+ public void Dispose()
+ {
+ this.Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Fired when the transmission handler is shutdown.
+ ///
+ ///
+ /// The number (non-negative) of milliseconds to wait, or
+ /// Timeout.Infinite to wait indefinitely.
+ ///
+ protected virtual void OnShutdown(int timeoutMilliseconds)
+ {
+ }
+
+ ///
+ /// Fired when a request could not be submitted.
+ ///
+ /// The request that was attempted to send to the server.
+ /// Length of content.
+ /// .
+ /// If the request is resubmitted and succeeds; otherwise, .
+ protected virtual bool OnSubmitRequestFailure(byte[] request, int contentLength, ExportClientResponse response)
+ {
+ return false;
+ }
+
+ ///
+ /// Fired when resending a request to the server.
+ ///
+ /// The request to be resent to the server.
+ /// Length of content.
+ /// The deadline time in utc for export request to finish.
+ /// .
+ /// If the retry succeeds; otherwise, .
+ protected bool TryRetryRequest(byte[] request, int contentLength, DateTime deadlineUtc, out ExportClientResponse response)
+ {
+ response = this.ExportClient.SendExportRequest(request, contentLength, deadlineUtc);
+ if (!response.Success)
+ {
+ OpenTelemetryProtocolExporterEventSource.Log.ExportMethodException(response.Exception, isRetry: true);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Releases the unmanaged resources used by this class and optionally
+ /// releases the managed resources.
+ ///
+ ///
+ /// to release both managed and unmanaged resources;
+ /// to release only unmanaged resources.
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpRetry.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpRetry.cs
new file mode 100644
index 00000000000..a7914c7c429
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/OtlpRetry.cs
@@ -0,0 +1,266 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Headers;
+using Google.Rpc;
+using Grpc.Core;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+using Status = Google.Rpc.Status;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+
+///
+/// Implementation of the OTLP retry policy used by both OTLP/gRPC and OTLP/HTTP.
+///
+/// OTLP/gRPC
+/// https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#failures
+///
+/// OTLP/HTTP
+/// https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#failures-1
+///
+/// The specification requires retries use an exponential backoff strategy,
+/// but does not provide specifics for the implementation. As such, this
+/// implementation is inspired by the retry strategy provided by
+/// Grpc.Net.Client which implements the gRPC retry specification.
+///
+/// Grpc.Net.Client retry implementation
+/// https://github.com/grpc/grpc-dotnet/blob/83d12ea1cb628156c990243bc98699829b88738b/src/Grpc.Net.Client/Internal/Retry/RetryCall.cs#L94
+///
+/// gRPC retry specification
+/// https://github.com/grpc/proposal/blob/master/A6-client-retries.md
+///
+/// The gRPC retry specification outlines configurable parameters used in its
+/// exponential backoff strategy: initial backoff, max backoff, backoff
+/// multiplier, and max retry attempts. The OTLP specification does not declare
+/// a similar set of parameters, so this implementation uses fixed settings.
+/// Furthermore, since the OTLP spec does not specify a max number of attempts,
+/// this implementation will retry until the deadline is reached.
+///
+/// The throttling mechanism for OTLP differs from the throttling mechanism
+/// described in the gRPC retry specification. See:
+/// https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#otlpgrpc-throttling.
+///
+internal static class OtlpRetry
+{
+ public const string GrpcStatusDetailsHeader = "grpc-status-details-bin";
+ public const int InitialBackoffMilliseconds = 1000;
+ private const int MaxBackoffMilliseconds = 5000;
+ private const double BackoffMultiplier = 1.5;
+
+#if !NET6_0_OR_GREATER
+ private static readonly Random Random = new Random();
+#endif
+
+ public static bool TryGetHttpRetryResult(ExportClientHttpResponse response, int retryDelayInMilliSeconds, out RetryResult retryResult)
+ {
+ if (response.StatusCode.HasValue)
+ {
+ return TryGetRetryResult(response.StatusCode.Value, IsHttpStatusCodeRetryable, response.DeadlineUtc, response.Headers, TryGetHttpRetryDelay, retryDelayInMilliSeconds, out retryResult);
+ }
+ else
+ {
+ if (ShouldHandleHttpRequestException(response.Exception))
+ {
+ var delay = TimeSpan.FromMilliseconds(GetRandomNumber(0, retryDelayInMilliSeconds));
+ if (!IsDeadlineExceeded(response.DeadlineUtc + delay))
+ {
+ retryResult = new RetryResult(false, delay, CalculateNextRetryDelay(retryDelayInMilliSeconds));
+ return true;
+ }
+ }
+
+ retryResult = default;
+ return false;
+ }
+ }
+
+ public static bool ShouldHandleHttpRequestException(Exception? exception)
+ {
+ // TODO: Handle specific exceptions.
+ return true;
+ }
+
+ public static bool TryGetGrpcRetryResult(ExportClientGrpcResponse response, int retryDelayMilliseconds, out RetryResult retryResult)
+ {
+ if (response.Exception is RpcException rpcException)
+ {
+ return TryGetRetryResult(rpcException.StatusCode, IsGrpcStatusCodeRetryable, response.DeadlineUtc, rpcException.Trailers, TryGetGrpcRetryDelay, retryDelayMilliseconds, out retryResult);
+ }
+
+ retryResult = default;
+ return false;
+ }
+
+ private static bool TryGetRetryResult(TStatusCode statusCode, Func isRetryable, DateTime? deadline, TCarrier carrier, Func throttleGetter, int nextRetryDelayMilliseconds, out RetryResult retryResult)
+ {
+ retryResult = default;
+
+ // TODO: Consider introducing a fixed max number of retries (e.g. max 5 retries).
+ // The spec does not specify a max number of retries, but it may be bad to not cap the number of attempts.
+ // Without a max number of retry attempts, retries would continue until the deadline.
+ // Maybe this is ok? However, it may lead to an unexpected behavioral change. For example:
+ // 1) When using a batch processor, a longer delay due to repeated
+ // retries up to the deadline may lead to a higher chance that the queue will be exhausted.
+ // 2) When using the simple processor, a longer delay due to repeated
+ // retries up to the deadline will lead to a prolonged blocking call.
+ // if (attemptCount >= MaxAttempts)
+ // {
+ // return false
+ // }
+
+ if (IsDeadlineExceeded(deadline))
+ {
+ return false;
+ }
+
+ var throttleDelay = throttleGetter(statusCode, carrier);
+ var retryable = isRetryable(statusCode, throttleDelay.HasValue);
+ if (!retryable)
+ {
+ return false;
+ }
+
+ var delayDuration = throttleDelay.HasValue
+ ? throttleDelay.Value
+ : TimeSpan.FromMilliseconds(GetRandomNumber(0, nextRetryDelayMilliseconds));
+
+ if (deadline.HasValue && IsDeadlineExceeded(deadline + delayDuration))
+ {
+ return false;
+ }
+
+ if (throttleDelay.HasValue)
+ {
+ try
+ {
+ // TODO: Consider making nextRetryDelayMilliseconds a double to avoid the need for convert/overflow handling
+ nextRetryDelayMilliseconds = Convert.ToInt32(throttleDelay.Value.TotalMilliseconds);
+ }
+ catch (OverflowException)
+ {
+ nextRetryDelayMilliseconds = MaxBackoffMilliseconds;
+ }
+ }
+
+ nextRetryDelayMilliseconds = CalculateNextRetryDelay(nextRetryDelayMilliseconds);
+ retryResult = new RetryResult(throttleDelay.HasValue, delayDuration, nextRetryDelayMilliseconds);
+ return true;
+ }
+
+ private static bool IsDeadlineExceeded(DateTime? deadline)
+ {
+ // This implementation is internal, and it is guaranteed that deadline is UTC.
+ return deadline.HasValue && deadline <= DateTime.UtcNow;
+ }
+
+ private static int CalculateNextRetryDelay(int nextRetryDelayMilliseconds)
+ {
+ var nextMilliseconds = nextRetryDelayMilliseconds * BackoffMultiplier;
+ nextMilliseconds = Math.Min(nextMilliseconds, MaxBackoffMilliseconds);
+ return Convert.ToInt32(nextMilliseconds);
+ }
+
+ private static TimeSpan? TryGetGrpcRetryDelay(StatusCode statusCode, Metadata trailers)
+ {
+ Debug.Assert(trailers != null, "trailers was null");
+
+ if (statusCode != StatusCode.ResourceExhausted && statusCode != StatusCode.Unavailable)
+ {
+ return null;
+ }
+
+ var statusDetails = trailers!.Get(GrpcStatusDetailsHeader);
+ if (statusDetails != null && statusDetails.IsBinary)
+ {
+ // TODO: Remove google.protobuf dependency for de-serialization.
+ var status = Status.Parser.ParseFrom(statusDetails.ValueBytes);
+ foreach (var item in status.Details)
+ {
+ var success = item.TryUnpack(out var retryInfo);
+ if (success)
+ {
+ return retryInfo.RetryDelay.ToTimeSpan();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static TimeSpan? TryGetHttpRetryDelay(HttpStatusCode statusCode, HttpResponseHeaders? responseHeaders)
+ {
+#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER
+ return statusCode == HttpStatusCode.TooManyRequests || statusCode == HttpStatusCode.ServiceUnavailable
+#else
+ return statusCode == (HttpStatusCode)429 || statusCode == HttpStatusCode.ServiceUnavailable
+#endif
+ ? responseHeaders?.RetryAfter?.Delta
+ : null;
+ }
+
+ private static bool IsGrpcStatusCodeRetryable(StatusCode statusCode, bool hasRetryDelay)
+ {
+ switch (statusCode)
+ {
+ case StatusCode.Cancelled:
+ case StatusCode.DeadlineExceeded:
+ case StatusCode.Aborted:
+ case StatusCode.OutOfRange:
+ case StatusCode.Unavailable:
+ case StatusCode.DataLoss:
+ return true;
+ case StatusCode.ResourceExhausted:
+ return hasRetryDelay;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsHttpStatusCodeRetryable(HttpStatusCode statusCode, bool hasRetryDelay)
+ {
+ switch (statusCode)
+ {
+#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER
+ case HttpStatusCode.TooManyRequests:
+#else
+ case (HttpStatusCode)429:
+#endif
+ case HttpStatusCode.BadGateway:
+ case HttpStatusCode.ServiceUnavailable:
+ case HttpStatusCode.GatewayTimeout:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static int GetRandomNumber(int min, int max)
+ {
+#if NET6_0_OR_GREATER
+ return Random.Shared.Next(min, max);
+#else
+ // TODO: Implement this better to minimize lock contention.
+ // Consider pulling in Random.Shared implementation.
+ lock (Random)
+ {
+ return Random.Next(min, max);
+ }
+#endif
+ }
+
+ public readonly struct RetryResult
+ {
+ public readonly bool Throttled;
+ public readonly TimeSpan RetryDelay;
+ public readonly int NextRetryDelayMilliseconds;
+
+ public RetryResult(bool throttled, TimeSpan retryDelay, int nextRetryDelayMilliseconds)
+ {
+ this.Throttled = throttled;
+ this.RetryDelay = retryDelay;
+ this.NextRetryDelayMilliseconds = nextRetryDelayMilliseconds;
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/RetryHelper.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/RetryHelper.cs
new file mode 100644
index 00000000000..a167b6a89b7
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Custom/Transmission/RetryHelper.cs
@@ -0,0 +1,30 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+
+internal static class RetryHelper
+{
+ internal static bool ShouldRetryRequest(ExportClientResponse response, int retryDelayMilliseconds, out OtlpRetry.RetryResult retryResult)
+ {
+ if (response is ExportClientGrpcResponse grpcResponse)
+ {
+ if (OtlpRetry.TryGetGrpcRetryResult(grpcResponse, retryDelayMilliseconds, out retryResult))
+ {
+ return true;
+ }
+ }
+ else if (response is ExportClientHttpResponse httpResponse)
+ {
+ if (OtlpRetry.TryGetHttpRetryResult(httpResponse, retryDelayMilliseconds, out retryResult))
+ {
+ return true;
+ }
+ }
+
+ retryResult = default;
+ return false;
+ }
+}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExperimentalOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExperimentalOptions.cs
index 25b345ac96e..f86743325db 100644
--- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExperimentalOptions.cs
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExperimentalOptions.cs
@@ -17,6 +17,8 @@ internal sealed class ExperimentalOptions
public const string OtlpDiskRetryDirectoryPathEnvVar = "OTEL_DOTNET_EXPERIMENTAL_OTLP_DISK_RETRY_DIRECTORY_PATH";
+ public const string OtlpUseCustomSerializer = "OTEL_DOTNET_EXPERIMENTAL_USE_CUSTOM_PROTOBUF_SERIALIZER";
+
public ExperimentalOptions()
: this(new ConfigurationBuilder().AddEnvironmentVariables().Build())
{
@@ -29,6 +31,11 @@ public ExperimentalOptions(IConfiguration configuration)
this.EmitLogEventAttributes = emitLogEventAttributes;
}
+ if (configuration.TryGetBoolValue(OpenTelemetryProtocolExporterEventSource.Log, OtlpUseCustomSerializer, out var useCustomSerializer))
+ {
+ this.UseCustomProtobufSerializer = useCustomSerializer;
+ }
+
if (configuration.TryGetStringValue(OtlpRetryEnvVar, out var retryPolicy) && retryPolicy != null)
{
if (retryPolicy.Equals("in_memory", StringComparison.OrdinalIgnoreCase))
@@ -78,4 +85,9 @@ public ExperimentalOptions(IConfiguration configuration)
/// Gets the path on disk where the telemetry will be stored for retries at a later point.
///
public string? DiskRetryDirectoryPath { get; }
+
+ ///
+ /// Gets a value indicating whether custom serializer should be used for OTLP export.
+ ///
+ public bool UseCustomProtobufSerializer { get; }
}
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj
index 2c933d89a33..245eb3ea0be 100644
--- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OpenTelemetry.Exporter.OpenTelemetryProtocol.csproj
@@ -16,6 +16,7 @@
https://github.com/open-telemetry/opentelemetry-dotnet/pull/5520#discussion_r1556221048
and https://github.com/dotnet/runtime/issues/92509 -->
$(NoWarn);SYSLIB1100;SYSLIB1101
+ true
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs
index b755e15880b..7ad24aa07cb 100644
--- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs
@@ -13,6 +13,8 @@
#endif
using System.Diagnostics;
using Google.Protobuf;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Transmission;
using LogOtlpCollector = OpenTelemetry.Proto.Collector.Logs.V1;
using MetricsOtlpCollector = OpenTelemetry.Proto.Collector.Metrics.V1;
@@ -127,6 +129,96 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac
}
}
+ public static OtlpExporterTransmissionHandler GetTraceExportTransmissionHandlerNew(this OtlpExporterOptions options, ExperimentalOptions experimentalOptions)
+ {
+ var exportClient = GetTraceExportClientNew(options);
+
+ // `HttpClient.Timeout.TotalMilliseconds` would be populated with the correct timeout value for both the exporter configuration cases:
+ // 1. User provides their own HttpClient. This case is straightforward as the user wants to use their `HttpClient` and thereby the same client's timeout value.
+ // 2. If the user configures timeout via the exporter options, then the timeout set for the `HttpClient` initialized by the exporter will be set to user provided value.
+ double timeoutMilliseconds = exportClient is OtlpHttpExportClient httpTraceExportClient
+ ? httpTraceExportClient.HttpClient.Timeout.TotalMilliseconds
+ : options.TimeoutMilliseconds;
+
+ if (experimentalOptions.EnableInMemoryRetry)
+ {
+ return new OtlpExporterRetryTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ else if (experimentalOptions.EnableDiskRetry)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(experimentalOptions.DiskRetryDirectoryPath), $"{nameof(experimentalOptions.DiskRetryDirectoryPath)} is null or empty");
+
+ return new OtlpExporterPersistentStorageTransmissionHandler(
+ exportClient,
+ timeoutMilliseconds,
+ Path.Combine(experimentalOptions.DiskRetryDirectoryPath, "traces"));
+ }
+ else
+ {
+ return new OtlpExporterTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ }
+
+ public static OtlpExporterTransmissionHandler GetMetricsExportTransmissionHandlerNew(this OtlpExporterOptions options, ExperimentalOptions experimentalOptions)
+ {
+ var exportClient = GetMetricsExportClientNew(options);
+
+ // `HttpClient.Timeout.TotalMilliseconds` would be populated with the correct timeout value for both the exporter configuration cases:
+ // 1. User provides their own HttpClient. This case is straightforward as the user wants to use their `HttpClient` and thereby the same client's timeout value.
+ // 2. If the user configures timeout via the exporter options, then the timeout set for the `HttpClient` initialized by the exporter will be set to user provided value.
+ double timeoutMilliseconds = exportClient is OtlpHttpExportClient httpTraceExportClient
+ ? httpTraceExportClient.HttpClient.Timeout.TotalMilliseconds
+ : options.TimeoutMilliseconds;
+
+ if (experimentalOptions.EnableInMemoryRetry)
+ {
+ return new OtlpExporterRetryTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ else if (experimentalOptions.EnableDiskRetry)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(experimentalOptions.DiskRetryDirectoryPath), $"{nameof(experimentalOptions.DiskRetryDirectoryPath)} is null or empty");
+
+ return new OtlpExporterPersistentStorageTransmissionHandler(
+ exportClient,
+ timeoutMilliseconds,
+ Path.Combine(experimentalOptions.DiskRetryDirectoryPath, "metrics"));
+ }
+ else
+ {
+ return new OtlpExporterTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ }
+
+ public static OtlpExporterTransmissionHandler GetLogsExportTransmissionHandlerNew(this OtlpExporterOptions options, ExperimentalOptions experimentalOptions)
+ {
+ var exportClient = GetLogsExportClientNew(options);
+
+ // `HttpClient.Timeout.TotalMilliseconds` would be populated with the correct timeout value for both the exporter configuration cases:
+ // 1. User provides their own HttpClient. This case is straightforward as the user wants to use their `HttpClient` and thereby the same client's timeout value.
+ // 2. If the user configures timeout via the exporter options, then the timeout set for the `HttpClient` initialized by the exporter will be set to user provided value.
+ double timeoutMilliseconds = exportClient is OtlpHttpExportClient httpTraceExportClient
+ ? httpTraceExportClient.HttpClient.Timeout.TotalMilliseconds
+ : options.TimeoutMilliseconds;
+
+ if (experimentalOptions.EnableInMemoryRetry)
+ {
+ return new OtlpExporterRetryTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ else if (experimentalOptions.EnableDiskRetry)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(experimentalOptions.DiskRetryDirectoryPath), $"{nameof(experimentalOptions.DiskRetryDirectoryPath)} is null or empty");
+
+ return new OtlpExporterPersistentStorageTransmissionHandler(
+ exportClient,
+ timeoutMilliseconds,
+ Path.Combine(experimentalOptions.DiskRetryDirectoryPath, "logs"));
+ }
+ else
+ {
+ return new OtlpExporterTransmissionHandler(exportClient, timeoutMilliseconds);
+ }
+ }
+
public static OtlpExporterTransmissionHandler GetMetricsExportTransmissionHandler(this OtlpExporterOptions options, ExperimentalOptions experimentalOptions)
{
var exportClient = GetMetricsExportClient(options);
@@ -195,6 +287,48 @@ public static THeaders GetHeaders(this OtlpExporterOptions options, Ac
}
}
+ public static IExportClient GetTraceExportClientNew(this OtlpExporterOptions options)
+ {
+ var httpClient = options.HttpClientFactory?.Invoke() ?? throw new InvalidOperationException("OtlpExporterOptions was missing HttpClientFactory or it returned null.");
+
+ if (options.Protocol == OtlpExportProtocol.Grpc)
+ {
+ return new OtlpGrpcExportClient(options, httpClient, "opentelemetry.proto.collector.trace.v1.TraceService/Export");
+ }
+ else
+ {
+ return new OtlpHttpExportClient(options, httpClient, "v1/traces");
+ }
+ }
+
+ public static IExportClient GetMetricsExportClientNew(this OtlpExporterOptions options)
+ {
+ var httpClient = options.HttpClientFactory?.Invoke() ?? throw new InvalidOperationException("OtlpExporterOptions was missing HttpClientFactory or it returned null.");
+
+ if (options.Protocol == OtlpExportProtocol.Grpc)
+ {
+ return new OtlpGrpcExportClient(options, httpClient, "opentelemetry.proto.collector.trace.v1.MetricService/Export");
+ }
+ else
+ {
+ return new OtlpHttpExportClient(options, httpClient, "v1/metrics");
+ }
+ }
+
+ public static IExportClient GetLogsExportClientNew(this OtlpExporterOptions options)
+ {
+ var httpClient = options.HttpClientFactory?.Invoke() ?? throw new InvalidOperationException("OtlpExporterOptions was missing HttpClientFactory or it returned null.");
+
+ if (options.Protocol == OtlpExportProtocol.Grpc)
+ {
+ return new OtlpGrpcExportClient(options, httpClient, "opentelemetry.proto.collector.trace.v1.LogService/Export");
+ }
+ else
+ {
+ return new OtlpHttpExportClient(options, httpClient, "v1/logs");
+ }
+ }
+
public static IExportClient GetTraceExportClient(this OtlpExporterOptions options) =>
options.Protocol switch
{
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterHelperExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterHelperExtensions.cs
index 3a18b3da423..b4d79bf1307 100644
--- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterHelperExtensions.cs
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterHelperExtensions.cs
@@ -136,7 +136,16 @@ internal static BaseProcessor BuildOtlpExporterProcessor(
exporterOptions!.TryEnableIHttpClientFactoryIntegration(serviceProvider!, "OtlpTraceExporter");
- BaseExporter otlpExporter = new OtlpTraceExporter(exporterOptions!, sdkLimitOptions!, experimentalOptions!);
+ BaseExporter otlpExporter;
+
+ if (experimentalOptions != null && experimentalOptions.UseCustomProtobufSerializer)
+ {
+ otlpExporter = new OtlpTraceExporterNew(exporterOptions!, sdkLimitOptions!, experimentalOptions!);
+ }
+ else
+ {
+ otlpExporter = new OtlpTraceExporter(exporterOptions!, sdkLimitOptions!, experimentalOptions!);
+ }
if (configureExporterInstance != null)
{
diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterNew.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterNew.cs
new file mode 100644
index 00000000000..896e95d54dc
--- /dev/null
+++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTraceExporterNew.cs
@@ -0,0 +1,86 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+using OpenTelemetry.Resources;
+
+namespace OpenTelemetry.Exporter;
+
+///
+/// Exporter consuming and exporting the data using
+/// the OpenTelemetry protocol (OTLP).
+///
+internal class OtlpTraceExporterNew : BaseExporter
+{
+ [ThreadStatic]
+ private static byte[]? buffer;
+
+ private readonly OtlpExporterTransmissionHandler transmissionHandler;
+ private readonly ActivitySerializer activitySerializer;
+ private readonly int bufferOffSet;
+
+ private Resource? resource;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Configuration options for the export.
+ internal OtlpTraceExporterNew(OtlpExporterOptions options)
+ : this(options, sdkLimitOptions: new(), experimentalOptions: new(), transmissionHandler: null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// .
+ /// .
+ /// .
+ /// .
+ internal OtlpTraceExporterNew(
+ OtlpExporterOptions exporterOptions,
+ SdkLimitOptions sdkLimitOptions,
+ ExperimentalOptions experimentalOptions,
+ OtlpExporterTransmissionHandler? transmissionHandler = null)
+ {
+ Debug.Assert(exporterOptions != null, "exporterOptions was null");
+
+ this.transmissionHandler = transmissionHandler ?? exporterOptions!.GetTraceExportTransmissionHandlerNew(experimentalOptions);
+ this.activitySerializer = new ActivitySerializer(sdkLimitOptions);
+
+ if (exporterOptions!.Protocol == OtlpExportProtocol.Grpc)
+ {
+ // Leave space for compression flag and mesage length.
+ this.bufferOffSet = 5;
+ }
+ }
+
+ internal Resource? Resource => this.resource ??= this.ParentProvider?.GetResource();
+
+ ///
+ public override ExportResult Export(in Batch activityBatch)
+ {
+ // Prevents the exporter's gRPC and HTTP operations from being instrumented.
+ using var scope = SuppressInstrumentationScope.Begin();
+
+ buffer ??= new byte[100000];
+
+ var cursor = this.activitySerializer.Serialize(ref buffer, this.bufferOffSet, this.Resource, activityBatch);
+
+ if (!this.transmissionHandler.TrySubmitRequest(buffer, cursor))
+ {
+ return ExportResult.Failure;
+ }
+
+ return ExportResult.Success;
+ }
+
+ ///
+ protected override bool OnShutdown(int timeoutMilliseconds)
+ {
+ return this.transmissionHandler.Shutdown(timeoutMilliseconds);
+ }
+}
diff --git a/test/Benchmarks/Exporter/OtlpTraceExporterBenchmarks.cs b/test/Benchmarks/Exporter/OtlpTraceExporterBenchmarks.cs
index 392e0612c0e..f99f7486396 100644
--- a/test/Benchmarks/Exporter/OtlpTraceExporterBenchmarks.cs
+++ b/test/Benchmarks/Exporter/OtlpTraceExporterBenchmarks.cs
@@ -37,6 +37,7 @@ namespace Benchmarks.Exporter;
public class OtlpTraceExporterBenchmarks
{
private OtlpTraceExporter exporter;
+ private OtlpTraceExporterNew newExporter;
private Activity activity;
private CircularBuffer activityBatch;
@@ -76,6 +77,37 @@ public void GlobalSetupGrpc()
this.activityBatch.Add(this.activity);
}
+ [GlobalSetup(Target = nameof(OtlpTraceExporter_Grpc_Custom))]
+ public void GlobalSetupGrpcCustom()
+ {
+ this.host = new HostBuilder()
+ .ConfigureWebHostDefaults(webBuilder => webBuilder
+ .ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(4317, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddGrpc();
+ })
+ .Configure(app =>
+ {
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGrpcService();
+ });
+ }))
+ .Start();
+
+ var options = new OtlpExporterOptions();
+ this.newExporter = new OtlpTraceExporterNew(options);
+
+ this.activity = ActivityHelper.CreateTestActivity();
+ this.activityBatch = new CircularBuffer(1);
+ this.activityBatch.Add(this.activity);
+ }
+
[GlobalSetup(Target = nameof(OtlpTraceExporter_Http))]
public void GlobalSetupHttp()
{
@@ -100,6 +132,30 @@ public void GlobalSetupHttp()
this.activityBatch.Add(this.activity);
}
+ [GlobalSetup(Target = nameof(OtlpTraceExporter_Http_Custom))]
+ public void GlobalSetupHttpCustom()
+ {
+ this.server = TestHttpServer.RunServer(
+ (ctx) =>
+ {
+ ctx.Response.StatusCode = 200;
+ ctx.Response.OutputStream.Close();
+ },
+ out this.serverHost,
+ out this.serverPort);
+
+ var options = new OtlpExporterOptions
+ {
+ Endpoint = new Uri($"http://{this.serverHost}:{this.serverPort}"),
+ Protocol = OtlpExportProtocol.HttpProtobuf,
+ };
+ this.newExporter = new OtlpTraceExporterNew(options);
+
+ this.activity = ActivityHelper.CreateTestActivity();
+ this.activityBatch = new CircularBuffer(1);
+ this.activityBatch.Add(this.activity);
+ }
+
[GlobalCleanup(Target = nameof(OtlpTraceExporter_Grpc))]
public void GlobalCleanupGrpc()
{
@@ -109,6 +165,15 @@ public void GlobalCleanupGrpc()
this.host.Dispose();
}
+ [GlobalCleanup(Target = nameof(OtlpTraceExporter_Grpc_Custom))]
+ public void GlobalCleanupGrpcCustom()
+ {
+ this.newExporter.Shutdown();
+ this.newExporter.Dispose();
+ this.activity.Dispose();
+ this.host.Dispose();
+ }
+
[GlobalCleanup(Target = nameof(OtlpTraceExporter_Http))]
public void GlobalCleanupHttp()
{
@@ -118,18 +183,39 @@ public void GlobalCleanupHttp()
this.activity.Dispose();
}
+ [GlobalCleanup(Target = nameof(OtlpTraceExporter_Http_Custom))]
+ public void GlobalCleanupHttpCustom()
+ {
+ this.newExporter.Shutdown();
+ this.newExporter.Dispose();
+ this.server.Dispose();
+ this.activity.Dispose();
+ }
+
[Benchmark]
public void OtlpTraceExporter_Http()
{
this.exporter.Export(new Batch(this.activityBatch, 1));
}
+ [Benchmark]
+ public void OtlpTraceExporter_Http_Custom()
+ {
+ this.newExporter.Export(new Batch(this.activityBatch, 1));
+ }
+
[Benchmark]
public void OtlpTraceExporter_Grpc()
{
this.exporter.Export(new Batch(this.activityBatch, 1));
}
+ [Benchmark]
+ public void OtlpTraceExporter_Grpc_Custom()
+ {
+ this.newExporter.Export(new Batch(this.activityBatch, 1));
+ }
+
private sealed class MockTraceService : OtlpCollector.TraceService.TraceServiceBase
{
private static OtlpCollector.ExportTraceServiceResponse response = new OtlpCollector.ExportTraceServiceResponse();
diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs
index e72a6773b81..fe530a80c8d 100644
--- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs
+++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs
@@ -6,6 +6,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -15,6 +16,8 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Transmission;
using OpenTelemetry.Metrics;
@@ -555,6 +558,439 @@ public async Task GrpcPersistentStorageRetryTests(bool usePersistentStorageTrans
transmissionHandler.Dispose();
}
+ // For `Grpc.Core.StatusCode.DeadlineExceeded`
+ // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/5436.
+ [Theory]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Unavailable)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Cancelled)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Aborted)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.OutOfRange)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.DataLoss)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Internal)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.InvalidArgument)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Unimplemented)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.FailedPrecondition)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.PermissionDenied)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Unauthenticated)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.DeadlineExceeded)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Unavailable)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Cancelled)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Aborted)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.OutOfRange)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.DataLoss)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Internal)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.InvalidArgument)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.FailedPrecondition)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.DeadlineExceeded)]
+ public async Task GrpcRetryTestsCustom(bool useRetryTransmissionHandler, ExportResult expectedResult, Grpc.Core.StatusCode initialStatusCode)
+ {
+ var testGrpcPort = Interlocked.Increment(ref gRPCPort);
+ var testHttpPort = Interlocked.Increment(ref httpPort);
+
+ using var host = await new HostBuilder()
+ .ConfigureWebHostDefaults(webBuilder => webBuilder
+ .ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(testHttpPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1);
+ options.ListenLocalhost(testGrpcPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new MockCollectorState());
+ services.AddGrpc();
+ })
+ .ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders())
+ .Configure(app =>
+ {
+ app.UseRouting();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet(
+ "/MockCollector/SetResponseCodes/{responseCodesCsv}",
+ (MockCollectorState collectorState, string responseCodesCsv) =>
+ {
+ var codes = responseCodesCsv.Split(",").Select(x => int.Parse(x)).ToArray();
+ collectorState.SetStatusCodes(codes);
+ });
+
+ endpoints.MapGrpcService();
+ });
+ }))
+ .StartAsync();
+
+ using var httpClient = new HttpClient() { BaseAddress = new Uri($"http://localhost:{testHttpPort}") };
+
+ // First reply with failure and then Ok
+ var codes = new[] { initialStatusCode, Grpc.Core.StatusCode.OK };
+ await httpClient.GetAsync($"/MockCollector/SetResponseCodes/{string.Join(",", codes.Select(x => (int)x))}");
+
+ var endpoint = new Uri($"http://localhost:{testGrpcPort}");
+
+ var exporterOptions = new OtlpExporterOptions() { Endpoint = endpoint, TimeoutMilliseconds = 200000, Protocol = OtlpExportProtocol.Grpc };
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [ExperimentalOptions.OtlpRetryEnvVar] = useRetryTransmissionHandler ? "in_memory" : null, [ExperimentalOptions.OtlpUseCustomSerializer] = "true" })
+ .Build();
+
+ var otlpExporter = new OtlpTraceExporterNew(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration));
+
+ var activitySourceName = "otel.http.grpc.retry.test";
+ using var source = new ActivitySource(activitySourceName);
+
+ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(activitySourceName)
+ .Build();
+
+ using var activity = source.StartActivity("GrpcRetryTestCustom");
+ Assert.NotNull(activity);
+ activity.Stop();
+ using var batch = new Batch([activity!], 1);
+
+ var exportResult = otlpExporter.Export(batch);
+
+ Assert.Equal(expectedResult, exportResult);
+
+ await host.StopAsync();
+ }
+
+ [Theory]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.ServiceUnavailable)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.BadGateway)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.GatewayTimeout)]
+ [InlineData(true, ExportResult.Failure, HttpStatusCode.BadRequest)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.TooManyRequests)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.ServiceUnavailable)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.BadGateway)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.GatewayTimeout)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.TooManyRequests)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.BadRequest)]
+ public async Task HttpRetryTestsCustom(bool useRetryTransmissionHandler, ExportResult expectedResult, HttpStatusCode initialHttpStatusCode)
+ {
+ var testHttpPort = Interlocked.Increment(ref httpPort);
+
+ using var host = await new HostBuilder()
+ .ConfigureWebHostDefaults(webBuilder => webBuilder
+ .ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(testHttpPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new MockCollectorHttpState());
+ })
+ .ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders())
+ .Configure(app =>
+ {
+ app.UseRouting();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet(
+ "/MockCollector/SetResponseCodes/{responseCodesCsv}",
+ (MockCollectorHttpState collectorState, string responseCodesCsv) =>
+ {
+ var codes = responseCodesCsv.Split(",").Select(x => int.Parse(x)).ToArray();
+ collectorState.SetStatusCodes(codes);
+ });
+
+ endpoints.MapPost("/v1/traces", async ctx =>
+ {
+ var state = ctx.RequestServices.GetRequiredService();
+ ctx.Response.StatusCode = (int)state.NextStatus();
+
+ await ctx.Response.WriteAsync("Request Received.");
+ });
+ });
+ }))
+ .StartAsync();
+
+ using var httpClient = new HttpClient() { BaseAddress = new Uri($"http://localhost:{testHttpPort}") };
+
+ var codes = new[] { initialHttpStatusCode, HttpStatusCode.OK };
+ await httpClient.GetAsync($"/MockCollector/SetResponseCodes/{string.Join(",", codes.Select(x => (int)x))}");
+
+ var endpoint = new Uri($"http://localhost:{testHttpPort}/v1/traces");
+
+ var exporterOptions = new OtlpExporterOptions() { Endpoint = endpoint, TimeoutMilliseconds = 20000, Protocol = OtlpExportProtocol.HttpProtobuf };
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [ExperimentalOptions.OtlpRetryEnvVar] = useRetryTransmissionHandler ? "in_memory" : null, [ExperimentalOptions.OtlpUseCustomSerializer] = "true" })
+ .Build();
+
+ var otlpExporter = new OtlpTraceExporterNew(exporterOptions, new SdkLimitOptions(), new ExperimentalOptions(configuration));
+
+ var activitySourceName = "otel.http.retry.test";
+ using var source = new ActivitySource(activitySourceName);
+
+ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(activitySourceName)
+ .Build();
+
+ using var activity = source.StartActivity("HttpRetryTestCustom");
+ Assert.NotNull(activity);
+ activity.Stop();
+ using var batch = new Batch([activity], 1);
+
+ var exportResult = otlpExporter.Export(batch);
+
+ Assert.Equal(expectedResult, exportResult);
+ }
+
+ [Theory]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.ServiceUnavailable)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.BadGateway)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.GatewayTimeout)]
+ [InlineData(true, ExportResult.Failure, HttpStatusCode.BadRequest)]
+ [InlineData(true, ExportResult.Success, HttpStatusCode.TooManyRequests)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.ServiceUnavailable)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.BadGateway)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.GatewayTimeout)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.TooManyRequests)]
+ [InlineData(false, ExportResult.Failure, HttpStatusCode.BadRequest)]
+ public async Task HttpPersistentStorageRetryTestsCustom(bool usePersistentStorageTransmissionHandler, ExportResult expectedResult, HttpStatusCode initialHttpStatusCode)
+ {
+ var testHttpPort = Interlocked.Increment(ref httpPort);
+
+ using var host = await new HostBuilder()
+ .ConfigureWebHostDefaults(webBuilder => webBuilder
+ .ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(testHttpPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new MockCollectorHttpState());
+ })
+ .ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders())
+ .Configure(app =>
+ {
+ app.UseRouting();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet(
+ "/MockCollector/SetResponseCodes/{responseCodesCsv}",
+ (MockCollectorHttpState collectorState, string responseCodesCsv) =>
+ {
+ var codes = responseCodesCsv.Split(",").Select(x => int.Parse(x)).ToArray();
+ collectorState.SetStatusCodes(codes);
+ });
+
+ endpoints.MapPost("/v1/traces", async ctx =>
+ {
+ var state = ctx.RequestServices.GetRequiredService();
+ ctx.Response.StatusCode = (int)state.NextStatus();
+
+ await ctx.Response.WriteAsync("Request Received.");
+ });
+ });
+ }))
+ .StartAsync();
+
+ using var httpClient = new HttpClient() { BaseAddress = new Uri($"http://localhost:{testHttpPort}") };
+
+ var codes = new[] { initialHttpStatusCode, HttpStatusCode.OK };
+ await httpClient.GetAsync($"/MockCollector/SetResponseCodes/{string.Join(",", codes.Select(x => (int)x))}");
+
+ var endpoint = new Uri($"http://localhost:{testHttpPort}/v1/traces");
+
+ var exporterOptions = new OtlpExporterOptions() { Endpoint = endpoint, TimeoutMilliseconds = 20000 };
+
+ var exportClient = new OtlpHttpExportClient(exporterOptions, new HttpClient(), "v1/traces");
+
+ // TODO: update this to configure via experimental environment variable.
+ OtlpExporterTransmissionHandler transmissionHandler;
+ MockFileProvider? mockProvider = null;
+ if (usePersistentStorageTransmissionHandler)
+ {
+ mockProvider = new MockFileProvider();
+ transmissionHandler = new OtlpExporterPersistentStorageTransmissionHandler(mockProvider, exportClient, exporterOptions.TimeoutMilliseconds);
+ }
+ else
+ {
+ transmissionHandler = new OtlpExporterTransmissionHandler(exportClient, exporterOptions.TimeoutMilliseconds);
+ }
+
+ var otlpExporter = new OtlpTraceExporterNew(exporterOptions, new(), new(), transmissionHandler);
+
+ var activitySourceName = "otel.http.persistent.storage.retry.test";
+ using var source = new ActivitySource(activitySourceName);
+
+ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(activitySourceName)
+ .Build();
+
+ using var activity = source.StartActivity("HttpPersistentStorageRetryTestCustom");
+ Assert.NotNull(activity);
+ activity.Stop();
+ using var batch = new Batch([activity], 1);
+
+ var exportResult = otlpExporter.Export(batch);
+
+ Assert.Equal(expectedResult, exportResult);
+
+ if (usePersistentStorageTransmissionHandler)
+ {
+ Assert.NotNull(mockProvider);
+ if (exportResult == ExportResult.Success)
+ {
+ Assert.Single(mockProvider.TryGetBlobs());
+
+ // Force Retry
+ Assert.True((transmissionHandler as OtlpExporterPersistentStorageTransmissionHandler)?.InitiateAndWaitForRetryProcess(-1));
+
+ Assert.False(mockProvider.TryGetBlob(out _));
+ }
+ else
+ {
+ Assert.Empty(mockProvider.TryGetBlobs());
+ }
+ }
+ else
+ {
+ Assert.Null(mockProvider);
+ }
+
+ transmissionHandler.Shutdown(0);
+
+ transmissionHandler.Dispose();
+ }
+
+ // For `Grpc.Core.StatusCode.DeadlineExceeded`
+ // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/5436.
+ [Theory]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Unavailable)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.ResourceExhausted)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Cancelled)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.Aborted)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.OutOfRange)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.DataLoss)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Internal)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.InvalidArgument)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Unimplemented)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.FailedPrecondition)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.PermissionDenied)]
+ [InlineData(true, ExportResult.Failure, Grpc.Core.StatusCode.Unauthenticated)]
+ [InlineData(true, ExportResult.Success, Grpc.Core.StatusCode.DeadlineExceeded)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Unavailable)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Cancelled)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Aborted)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.OutOfRange)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.DataLoss)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.Internal)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.InvalidArgument)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.FailedPrecondition)]
+ [InlineData(false, ExportResult.Failure, Grpc.Core.StatusCode.DeadlineExceeded)]
+ public async Task GrpcPersistentStorageRetryTestsCustom(bool usePersistentStorageTransmissionHandler, ExportResult expectedResult, Grpc.Core.StatusCode initialgrpcStatusCode)
+ {
+ var testGrpcPort = Interlocked.Increment(ref gRPCPort);
+ var testHttpPort = Interlocked.Increment(ref httpPort);
+
+ using var host = await new HostBuilder()
+ .ConfigureWebHostDefaults(webBuilder => webBuilder
+ .ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(testHttpPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1);
+ options.ListenLocalhost(testGrpcPort, listenOptions => listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
+ })
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new MockCollectorState());
+ services.AddGrpc();
+ })
+ .ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders())
+ .Configure(app =>
+ {
+ app.UseRouting();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet(
+ "/MockCollector/SetResponseCodes/{responseCodesCsv}",
+ (MockCollectorState collectorState, string responseCodesCsv) =>
+ {
+ var codes = responseCodesCsv.Split(",").Select(x => int.Parse(x)).ToArray();
+ collectorState.SetStatusCodes(codes);
+ });
+
+ endpoints.MapGrpcService();
+ });
+ }))
+ .StartAsync();
+
+ using var httpClient = new HttpClient() { BaseAddress = new Uri($"http://localhost:{testHttpPort}") };
+
+ var codes = new[] { initialgrpcStatusCode, Grpc.Core.StatusCode.OK };
+ await httpClient.GetAsync($"/MockCollector/SetResponseCodes/{string.Join(",", codes.Select(x => (int)x))}");
+
+ var endpoint = new Uri($"http://localhost:{testGrpcPort}");
+
+ var exporterOptions = new OtlpExporterOptions() { Endpoint = endpoint, TimeoutMilliseconds = 20000 };
+
+ var exportClient = new OtlpGrpcExportClient(exporterOptions, new HttpClient(), "opentelemetry.proto.collector.trace.v1.TraceService/Export");
+
+ // TODO: update this to configure via experimental environment variable.
+ OtlpExporterTransmissionHandler transmissionHandler;
+ MockFileProvider? mockProvider = null;
+ if (usePersistentStorageTransmissionHandler)
+ {
+ mockProvider = new MockFileProvider();
+ transmissionHandler = new OtlpExporterPersistentStorageTransmissionHandler(
+ mockProvider,
+ exportClient,
+ exporterOptions.TimeoutMilliseconds);
+ }
+ else
+ {
+ transmissionHandler = new OtlpExporterTransmissionHandler(exportClient, exporterOptions.TimeoutMilliseconds);
+ }
+
+ var otlpExporter = new OtlpTraceExporterNew(exporterOptions, new(), new(), transmissionHandler);
+
+ var activitySourceName = "otel.grpc.persistent.storage.retry.test";
+ using var source = new ActivitySource(activitySourceName);
+
+ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(activitySourceName)
+ .Build();
+
+ using var activity = source.StartActivity("GrpcPersistentStorageRetryTestCustom");
+ Assert.NotNull(activity);
+ activity.Stop();
+ using var batch = new Batch([activity], 1);
+
+ var exportResult = otlpExporter.Export(batch);
+
+ Assert.Equal(expectedResult, exportResult);
+
+ if (usePersistentStorageTransmissionHandler)
+ {
+ Assert.NotNull(mockProvider);
+ if (exportResult == ExportResult.Success)
+ {
+ Assert.Single(mockProvider.TryGetBlobs());
+
+ // Force Retry
+ Assert.True((transmissionHandler as OtlpExporterPersistentStorageTransmissionHandler)?.InitiateAndWaitForRetryProcess(-1));
+
+ Assert.False(mockProvider.TryGetBlob(out _));
+ }
+ else
+ {
+ Assert.Empty(mockProvider.TryGetBlobs());
+ }
+ }
+ else
+ {
+ Assert.Null(mockProvider);
+ }
+
+ transmissionHandler.Shutdown(0);
+
+ transmissionHandler.Dispose();
+ }
+
private class MockCollectorState
{
private Grpc.Core.StatusCode[] statusCodes = { };
@@ -605,13 +1041,34 @@ public MockTraceService(MockCollectorState state)
public override Task Export(ExportTraceServiceRequest request, ServerCallContext context)
{
var statusCode = this.state.NextStatus();
- if (statusCode != Grpc.Core.StatusCode.OK)
+ if (statusCode == Grpc.Core.StatusCode.ResourceExhausted)
+ {
+ throw new RpcException(new Grpc.Core.Status(statusCode, "resource"), GenerateTrailers(new Duration() { Seconds = 3 }));
+ }
+ else if (statusCode != Grpc.Core.StatusCode.OK)
{
throw new RpcException(new Grpc.Core.Status(statusCode, "Error."));
}
return Task.FromResult(new ExportTraceServiceResponse());
}
+
+ private static Metadata GenerateTrailers(Duration throttleDelay)
+ {
+ var metadata = new Metadata();
+
+ var retryInfo = new Google.Rpc.RetryInfo();
+ retryInfo.RetryDelay = throttleDelay;
+
+ var status = new Google.Rpc.Status();
+ status.Details.Add(Any.Pack(retryInfo));
+
+ var stream = new MemoryStream();
+ status.WriteTo(stream);
+
+ metadata.Add("grpc-status-details-bin", stream.ToArray());
+ return metadata;
+ }
}
private class MockFileProvider : PersistentBlobProvider
diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterNewTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterNewTests.cs
new file mode 100644
index 00000000000..b7712f3d0fa
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTraceExporterNewTests.cs
@@ -0,0 +1,928 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using Google.Protobuf;
+using Google.Protobuf.Collections;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Serializer;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.Transmission;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Tests;
+using OpenTelemetry.Trace;
+using Xunit;
+using OtlpCollector = OpenTelemetry.Proto.Collector.Trace.V1;
+using OtlpCommon = OpenTelemetry.Proto.Common.V1;
+using OtlpTrace = OpenTelemetry.Proto.Trace.V1;
+using Status = OpenTelemetry.Trace.Status;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests;
+
+[Collection("xUnitCollectionPreventingTestsThatDependOnSdkConfigurationFromRunningInParallel")]
+public class OtlpTraceExporterNewTests
+{
+ private static readonly SdkLimitOptions DefaultSdkLimitOptions = new();
+
+ private static readonly ExperimentalOptions DefaultExperimentalOptions = new();
+
+ private static IConfigurationRoot configuration;
+
+ static OtlpTraceExporterNewTests()
+ {
+ Activity.DefaultIdFormat = ActivityIdFormat.W3C;
+ Activity.ForceDefaultIdFormat = true;
+
+ var listener = new ActivityListener
+ {
+ ShouldListenTo = _ => true,
+ Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData,
+ };
+
+ ActivitySource.AddActivityListener(listener);
+
+ configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [ExperimentalOptions.OtlpUseCustomSerializer] = "true" })
+ .Build();
+ }
+
+ [Fact]
+ public void AddOtlpTraceExporterNamedOptionsSupported()
+ {
+ int defaultExporterOptionsConfigureOptionsInvocations = 0;
+ int namedExporterOptionsConfigureOptionsInvocations = 0;
+
+ using var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(configuration);
+ services.Configure(o => defaultExporterOptionsConfigureOptionsInvocations++);
+
+ services.Configure("Exporter2", o => namedExporterOptionsConfigureOptionsInvocations++);
+ })
+ .AddOtlpExporter()
+ .AddOtlpExporter("Exporter2", o => { })
+ .Build();
+
+ Assert.Equal(1, defaultExporterOptionsConfigureOptionsInvocations);
+ Assert.Equal(1, namedExporterOptionsConfigureOptionsInvocations);
+ }
+
+ [Fact]
+ public void OtlpExporter_BadArgs()
+ {
+ TracerProviderBuilder? builder = null;
+ Assert.Throws(() => builder!
+ .ConfigureServices(services => { services.AddSingleton(configuration); })
+ .AddOtlpExporter());
+ }
+
+ [Fact]
+ public void UserHttpFactoryCalled()
+ {
+ OtlpExporterOptions options = new OtlpExporterOptions();
+
+ var defaultFactory = options.HttpClientFactory;
+
+ int invocations = 0;
+ options.Protocol = OtlpExportProtocol.HttpProtobuf;
+ options.HttpClientFactory = () =>
+ {
+ invocations++;
+ return defaultFactory();
+ };
+
+ using (var exporter = new OtlpTraceExporterNew(options))
+ {
+ Assert.Equal(1, invocations);
+ }
+
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .ConfigureServices(services => { services.AddSingleton(configuration); })
+ .AddOtlpExporter(o =>
+ {
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ o.HttpClientFactory = options.HttpClientFactory;
+ })
+ .Build())
+ {
+ Assert.Equal(2, invocations);
+ }
+
+ options.HttpClientFactory = () => null!;
+ Assert.Throws(() =>
+ {
+ using var exporter = new OtlpTraceExporter(options);
+ });
+ }
+
+ [Fact]
+ public void ServiceProviderHttpClientFactoryInvoked()
+ {
+ IServiceCollection services = new ServiceCollection();
+
+ services.AddHttpClient();
+
+ int invocations = 0;
+
+ services.AddHttpClient("OtlpTraceExporter", configureClient: (client) => invocations++);
+
+ services.AddOpenTelemetry().WithTracing(builder => builder
+ .ConfigureServices(services => { services.AddSingleton(configuration); })
+ .AddOtlpExporter(o => o.Protocol = OtlpExportProtocol.HttpProtobuf));
+
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var tracerProvider = serviceProvider.GetRequiredService();
+
+ Assert.Equal(1, invocations);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ToOtlpResourceSpansTest(bool includeServiceNameInResource)
+ {
+ var evenTags = new[] { new KeyValuePair("k0", "v0") };
+ var oddTags = new[] { new KeyValuePair("k1", "v1") };
+ var sources = new[]
+ {
+ new ActivitySource("even", "2.4.6"),
+ new ActivitySource("odd", "1.3.5"),
+ };
+
+ var resourceBuilder = ResourceBuilder.CreateEmpty();
+ if (includeServiceNameInResource)
+ {
+ resourceBuilder.AddService("service-name", "ns1");
+ }
+
+ var exportedItems = new List();
+ var builder = Sdk.CreateTracerProviderBuilder()
+ .SetResourceBuilder(resourceBuilder)
+ .AddSource(sources[0].Name)
+ .AddSource(sources[1].Name)
+ .AddProcessor(new SimpleActivityExportProcessor(new InMemoryExporter(exportedItems)));
+
+ using var openTelemetrySdk = builder.Build();
+
+ const int numOfSpans = 10;
+ bool isEven;
+ for (var i = 0; i < numOfSpans; i++)
+ {
+ isEven = i % 2 == 0;
+ var source = sources[i % 2];
+ var activityKind = isEven ? ActivityKind.Client : ActivityKind.Server;
+ var activityTags = isEven ? evenTags : oddTags;
+
+ using Activity? activity = source.StartActivity($"span-{i}", activityKind, parentContext: default, activityTags!);
+ }
+
+ Assert.Equal(10, exportedItems.Count);
+ var batch = new Batch(exportedItems.ToArray(), exportedItems.Count);
+ RunTest(DefaultSdkLimitOptions, batch);
+
+ void RunTest(SdkLimitOptions sdkOptions, Batch batch)
+ {
+ var request = new OtlpCollector.ExportTraceServiceRequest();
+
+ var traceSerializer = new ActivitySerializer(sdkOptions);
+
+ byte[] buffer = new byte[10000];
+
+ var cursor = traceSerializer.Serialize(ref buffer, 0, resourceBuilder.Build(), batch);
+
+ var requestArray = new byte[cursor];
+
+ Buffer.BlockCopy(buffer, 0, requestArray, 0, cursor);
+
+ request.MergeFrom(requestArray);
+
+ Assert.Single(request.ResourceSpans);
+ var otlpResource = request.ResourceSpans.First().Resource;
+ if (includeServiceNameInResource)
+ {
+ Assert.Contains(otlpResource.Attributes, (kvp) => kvp.Key == ResourceSemanticConventions.AttributeServiceName && kvp.Value.StringValue == "service-name");
+ Assert.Contains(otlpResource.Attributes, (kvp) => kvp.Key == ResourceSemanticConventions.AttributeServiceNamespace && kvp.Value.StringValue == "ns1");
+ }
+ else
+ {
+ Assert.Null(otlpResource);
+
+ // TODO: Investigate why service name is added even though it is not part of the resource attributes.
+ // Assert.Contains(otlpResource.Attributes, (kvp) => kvp.Key == ResourceSemanticConventions.AttributeServiceName && kvp.Value.ToString().Contains("unknown_service:"));
+ }
+
+ var scopeSpans = request.ResourceSpans.First().ScopeSpans;
+ Assert.Equal(2, scopeSpans.Count);
+ foreach (var scope in scopeSpans)
+ {
+ Assert.Equal(numOfSpans / 2, scope.Spans.Count);
+ Assert.NotNull(scope.Scope);
+
+ var expectedSpanNames = new List();
+ var start = scope.Scope.Name == "even" ? 0 : 1;
+ for (var i = start; i < numOfSpans; i += 2)
+ {
+ expectedSpanNames.Add($"span-{i}");
+ }
+
+ var otlpSpans = scope.Spans;
+ Assert.Equal(expectedSpanNames.Count, otlpSpans.Count);
+
+ var kv0 = new OtlpCommon.KeyValue { Key = "k0", Value = new OtlpCommon.AnyValue { StringValue = "v0" } };
+ var kv1 = new OtlpCommon.KeyValue { Key = "k1", Value = new OtlpCommon.AnyValue { StringValue = "v1" } };
+
+ var expectedTag = scope.Scope.Name == "even"
+ ? kv0
+ : kv1;
+
+ foreach (var otlpSpan in otlpSpans)
+ {
+ Assert.Contains(otlpSpan.Name, expectedSpanNames);
+ Assert.Contains(expectedTag, otlpSpan.Attributes);
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void SpanLimitsTest()
+ {
+ var sdkOptions = new SdkLimitOptions()
+ {
+ AttributeValueLengthLimit = 4,
+ AttributeCountLimit = 3,
+ SpanEventCountLimit = 1,
+ SpanLinkCountLimit = 1,
+ };
+
+ var tags = new ActivityTagsCollection()
+ {
+ new KeyValuePair("TruncatedTag", "12345"),
+ new KeyValuePair("TruncatedStringArray", new string[] { "12345", "1234", string.Empty, null! }),
+ new KeyValuePair("TruncatedObjectTag", new object()),
+ new KeyValuePair("OneTagTooMany", 1),
+ };
+
+ var links = new[]
+ {
+ new ActivityLink(default, tags),
+ new ActivityLink(default, tags),
+ };
+
+ using var activitySource = new ActivitySource(nameof(this.SpanLimitsTest));
+ using var activity = activitySource.StartActivity("root", ActivityKind.Server, default(ActivityContext), tags, links);
+
+ Assert.NotNull(activity);
+
+ var event1 = new ActivityEvent("Event", DateTime.UtcNow, tags);
+ var event2 = new ActivityEvent("OneEventTooMany", DateTime.Now, tags);
+
+ activity.AddEvent(event1);
+ activity.AddEvent(event2);
+
+ var otlpSpan = GetOtlpSpan(activity, sdkOptions);
+
+ Assert.NotNull(otlpSpan);
+ Assert.Equal(3, otlpSpan.Attributes.Count);
+ Assert.Equal(1u, otlpSpan.DroppedAttributesCount);
+ Assert.Equal("1234", otlpSpan.Attributes[0].Value.StringValue);
+ ArrayValueAsserts(otlpSpan.Attributes[1].Value.ArrayValue.Values);
+ Assert.Equal(new object().ToString()?.Substring(0, 4), otlpSpan.Attributes[2].Value.StringValue);
+
+ Assert.Single(otlpSpan.Events);
+ Assert.Equal(1u, otlpSpan.DroppedEventsCount);
+ Assert.Equal(3, otlpSpan.Events[0].Attributes.Count);
+ Assert.Equal(1u, otlpSpan.Events[0].DroppedAttributesCount);
+ Assert.Equal("1234", otlpSpan.Events[0].Attributes[0].Value.StringValue);
+ ArrayValueAsserts(otlpSpan.Events[0].Attributes[1].Value.ArrayValue.Values);
+ Assert.Equal(new object().ToString()?.Substring(0, 4), otlpSpan.Events[0].Attributes[2].Value.StringValue);
+
+ Assert.Single(otlpSpan.Links);
+ Assert.Equal(1u, otlpSpan.DroppedLinksCount);
+ Assert.Equal(3, otlpSpan.Links[0].Attributes.Count);
+ Assert.Equal(1u, otlpSpan.Links[0].DroppedAttributesCount);
+ Assert.Equal("1234", otlpSpan.Links[0].Attributes[0].Value.StringValue);
+ ArrayValueAsserts(otlpSpan.Links[0].Attributes[1].Value.ArrayValue.Values);
+ Assert.Equal(new object().ToString()?.Substring(0, 4), otlpSpan.Links[0].Attributes[2].Value.StringValue);
+
+ void ArrayValueAsserts(RepeatedField values)
+ {
+ var expectedStringArray = new string[] { "1234", "1234", string.Empty, null! };
+ for (var i = 0; i < expectedStringArray.Length; ++i)
+ {
+ var expectedValue = expectedStringArray[i];
+ var expectedValueCase = expectedValue != null
+ ? OtlpCommon.AnyValue.ValueOneofCase.StringValue
+ : OtlpCommon.AnyValue.ValueOneofCase.None;
+
+ var actual = values[i];
+ Assert.Equal(expectedValueCase, actual.ValueCase);
+ if (expectedValueCase != OtlpCommon.AnyValue.ValueOneofCase.None)
+ {
+ Assert.Equal(expectedValue, actual.StringValue);
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void ToOtlpSpanTest()
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+
+ using var rootActivity = activitySource.StartActivity("root", ActivityKind.Producer);
+
+ Assert.NotNull(rootActivity);
+
+ var attributes = new List>
+ {
+ new KeyValuePair("bool", true),
+ new KeyValuePair("long", 1L),
+ new KeyValuePair("string", "text"),
+ new KeyValuePair("double", 3.14),
+ new KeyValuePair("int", 1),
+ new KeyValuePair("datetime", DateTime.UtcNow),
+ new KeyValuePair("bool_array", new bool[] { true, false }),
+ new KeyValuePair("int_array", new int[] { 1, 2 }),
+ new KeyValuePair("double_array", new double[] { 1.0, 2.09 }),
+ new KeyValuePair("string_array", new string[] { "a", "b" }),
+ new KeyValuePair("datetime_array", new DateTime[] { DateTime.UtcNow, DateTime.Now }),
+ };
+
+ foreach (var kvp in attributes)
+ {
+ rootActivity.SetTag(kvp.Key, kvp.Value);
+ }
+
+ var startTime = new DateTime(2020, 02, 20, 20, 20, 20, DateTimeKind.Utc);
+
+ DateTimeOffset dateTimeOffset;
+ dateTimeOffset = DateTimeOffset.FromUnixTimeMilliseconds(0);
+
+ var expectedUnixTimeTicks = (ulong)(startTime.Ticks - dateTimeOffset.Ticks);
+ var duration = TimeSpan.FromMilliseconds(1555);
+
+ rootActivity!.SetStartTime(startTime);
+ rootActivity.SetEndTime(startTime + duration);
+
+ Span traceIdSpan = stackalloc byte[16];
+ rootActivity.TraceId.CopyTo(traceIdSpan);
+ var traceId = traceIdSpan.ToArray();
+
+ var otlpSpan = GetOtlpSpan(rootActivity);
+
+ Assert.NotNull(otlpSpan);
+ Assert.Equal("root", otlpSpan.Name);
+ Assert.Equal(OtlpTrace.Span.Types.SpanKind.Producer, otlpSpan.Kind);
+ Assert.Equal(traceId, otlpSpan.TraceId);
+ Assert.Empty(otlpSpan.ParentSpanId);
+ Assert.Null(otlpSpan.Status);
+ Assert.Empty(otlpSpan.Events);
+ Assert.Empty(otlpSpan.Links);
+ OtlpTestHelpers.AssertOtlpAttributes(attributes!, otlpSpan.Attributes);
+
+ var expectedStartTimeUnixNano = 100 * expectedUnixTimeTicks;
+ Assert.Equal(expectedStartTimeUnixNano, otlpSpan.StartTimeUnixNano);
+ var expectedEndTimeUnixNano = expectedStartTimeUnixNano + (duration.TotalMilliseconds * 1_000_000);
+ Assert.Equal(expectedEndTimeUnixNano, otlpSpan.EndTimeUnixNano);
+
+ var childLinks = new List { new ActivityLink(rootActivity.Context, new ActivityTagsCollection(attributes!)) };
+ var childActivity = activitySource.StartActivity(
+ "child",
+ ActivityKind.Client,
+ rootActivity.Context,
+ links: childLinks);
+
+ Assert.NotNull(childActivity);
+
+ childActivity.SetStatus(Status.Error);
+
+ var childEvents = new List { new ActivityEvent("e0"), new ActivityEvent("e1", default, new ActivityTagsCollection(attributes!)) };
+ childActivity!.AddEvent(childEvents[0]);
+ childActivity.AddEvent(childEvents[1]);
+
+ Span parentIdSpan = stackalloc byte[8];
+ rootActivity.Context.SpanId.CopyTo(parentIdSpan);
+ var parentId = parentIdSpan.ToArray();
+
+ otlpSpan = GetOtlpSpan(childActivity);
+
+ Assert.NotNull(otlpSpan);
+ Assert.Equal("child", otlpSpan.Name);
+ Assert.Equal(OtlpTrace.Span.Types.SpanKind.Client, otlpSpan.Kind);
+ Assert.Equal(traceId, otlpSpan.TraceId);
+ Assert.Equal(parentId, otlpSpan.ParentSpanId);
+
+ // Assert.Equal(OtlpTrace.Status.Types.StatusCode.NotFound, otlpSpan.Status.Code);
+
+ Assert.Equal(Status.Error.Description ?? string.Empty, otlpSpan.Status.Message);
+ Assert.Empty(otlpSpan.Attributes);
+
+ Assert.Equal(childEvents.Count, otlpSpan.Events.Count);
+ for (var i = 0; i < childEvents.Count; i++)
+ {
+ Assert.Equal(childEvents[i].Name, otlpSpan.Events[i].Name);
+ OtlpTestHelpers.AssertOtlpAttributes(childEvents[i].Tags.ToList(), otlpSpan.Events[i].Attributes);
+ }
+
+ childLinks.Reverse();
+ Assert.Equal(childLinks.Count, otlpSpan.Links.Count);
+ for (var i = 0; i < childLinks.Count; i++)
+ {
+ OtlpTestHelpers.AssertOtlpAttributes(childLinks[i].Tags!.ToList(), otlpSpan.Links[i].Attributes);
+ }
+
+ var flags = (OtlpTrace.SpanFlags)otlpSpan.Flags;
+ Assert.True(flags.HasFlag(OtlpTrace.SpanFlags.ContextHasIsRemoteMask));
+ Assert.False(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask));
+ }
+
+ [Fact]
+ public void ToOtlpSpanActivitiesWithNullArrayTest()
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+
+ using var rootActivity = activitySource.StartActivity("root", ActivityKind.Client);
+ Assert.NotNull(rootActivity);
+
+ var stringArr = new string[] { "test", string.Empty, null! };
+ rootActivity.SetTag("stringArray", stringArr);
+
+ var otlpSpan = GetOtlpSpan(rootActivity);
+
+ Assert.NotNull(otlpSpan);
+
+ var stringArray = otlpSpan.Attributes.FirstOrDefault(kvp => kvp.Key == "stringArray");
+
+ Assert.NotNull(stringArray);
+ Assert.Equal("test", stringArray.Value.ArrayValue.Values[0].StringValue);
+ Assert.Equal(string.Empty, stringArray.Value.ArrayValue.Values[1].StringValue);
+ Assert.Equal(OtlpCommon.AnyValue.ValueOneofCase.None, stringArray.Value.ArrayValue.Values[2].ValueCase);
+ }
+
+ [Theory]
+ [InlineData(ActivityStatusCode.Unset, "Description will be ignored if status is Unset.")]
+ [InlineData(ActivityStatusCode.Ok, "Description will be ignored if status is Okay.")]
+ [InlineData(ActivityStatusCode.Error, "Description will be kept if status is Error.")]
+ public void ToOtlpSpanNativeActivityStatusTest(ActivityStatusCode expectedStatusCode, string statusDescription)
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ Assert.NotNull(activity);
+ activity.SetStatus(expectedStatusCode, statusDescription);
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ if (expectedStatusCode == ActivityStatusCode.Unset)
+ {
+ Assert.Null(otlpSpan.Status);
+ }
+ else
+ {
+ Assert.NotNull(otlpSpan.Status);
+ Assert.Equal((int)expectedStatusCode, (int)otlpSpan.Status.Code);
+ if (expectedStatusCode == ActivityStatusCode.Error)
+ {
+ Assert.Equal(statusDescription, otlpSpan.Status.Message);
+ }
+
+ if (expectedStatusCode == ActivityStatusCode.Ok)
+ {
+ Assert.Empty(otlpSpan.Status.Message);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(StatusCode.Unset, "Unset", "Description will be ignored if status is Unset.")]
+ [InlineData(StatusCode.Ok, "Ok", "Description must only be used with the Error StatusCode.")]
+ [InlineData(StatusCode.Error, "Error", "Error description.")]
+ public void ToOtlpSpanStatusTagTest(StatusCode expectedStatusCode, string statusCodeTagValue, string statusDescription)
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ Assert.NotNull(activity);
+ activity.SetTag(SpanAttributeConstants.StatusCodeKey, statusCodeTagValue);
+ activity.SetTag(SpanAttributeConstants.StatusDescriptionKey, statusDescription);
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ if (expectedStatusCode == StatusCode.Unset)
+ {
+ Assert.Null(otlpSpan.Status);
+ }
+ else
+ {
+ Assert.NotNull(otlpSpan.Status);
+ Assert.Equal((int)expectedStatusCode, (int)otlpSpan.Status.Code);
+
+ if (expectedStatusCode == StatusCode.Error)
+ {
+ Assert.Equal(statusDescription, otlpSpan.Status.Message);
+ }
+ else
+ {
+ Assert.Empty(otlpSpan.Status.Message);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(StatusCode.Unset, "uNsET")]
+ [InlineData(StatusCode.Ok, "oK")]
+ [InlineData(StatusCode.Error, "ERROR")]
+ public void ToOtlpSpanStatusTagIsCaseInsensitiveTest(StatusCode expectedStatusCode, string statusCodeTagValue)
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ Assert.NotNull(activity);
+ activity.SetTag(SpanAttributeConstants.StatusCodeKey, statusCodeTagValue);
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ if (expectedStatusCode == StatusCode.Unset)
+ {
+ Assert.Null(otlpSpan.Status);
+ }
+ else
+ {
+ Assert.NotNull(otlpSpan.Status);
+ Assert.Equal((int)expectedStatusCode, (int)otlpSpan.Status.Code);
+ }
+ }
+
+ [Fact]
+ public void ToOtlpSpanActivityStatusTakesPrecedenceOverStatusTagsWhenActivityStatusCodeIsOk()
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ const string TagDescriptionOnError = "Description when TagStatusCode is Error.";
+ Assert.NotNull(activity);
+ activity.SetStatus(ActivityStatusCode.Ok);
+ activity.SetTag(SpanAttributeConstants.StatusCodeKey, "ERROR");
+ activity.SetTag(SpanAttributeConstants.StatusDescriptionKey, TagDescriptionOnError);
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ Assert.NotNull(otlpSpan.Status);
+ Assert.Equal((int)ActivityStatusCode.Ok, (int)otlpSpan.Status.Code);
+ Assert.Empty(otlpSpan.Status.Message);
+ }
+
+ [Fact]
+ public void ToOtlpSpanActivityStatusTakesPrecedenceOverStatusTagsWhenActivityStatusCodeIsError()
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ const string StatusDescriptionOnError = "Description when ActivityStatusCode is Error.";
+ Assert.NotNull(activity);
+ activity.SetStatus(ActivityStatusCode.Error, StatusDescriptionOnError);
+ activity.SetTag(SpanAttributeConstants.StatusCodeKey, "OK");
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ Assert.NotNull(otlpSpan.Status);
+ Assert.Equal((int)ActivityStatusCode.Error, (int)otlpSpan.Status.Code);
+ Assert.Equal(StatusDescriptionOnError, otlpSpan.Status.Message);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ToOtlpSpanTraceStateTest(bool traceStateWasSet)
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+ using var activity = activitySource.StartActivity("Name");
+ Assert.NotNull(activity);
+ string tracestate = "a=b;c=d";
+ if (traceStateWasSet)
+ {
+ activity.TraceStateString = tracestate;
+ }
+
+ var otlpSpan = GetOtlpSpan(activity);
+
+ if (traceStateWasSet)
+ {
+ Assert.NotNull(otlpSpan.TraceState);
+ Assert.Equal(tracestate, otlpSpan.TraceState);
+ }
+ else
+ {
+ Assert.Equal(string.Empty, otlpSpan.TraceState);
+ }
+ }
+
+ // Should this be bug fix?
+ [Fact(Skip = "https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/1761")]
+ public void ToOtlpSpanPeerServiceTest()
+ {
+ using var activitySource = new ActivitySource(nameof(this.ToOtlpSpanTest));
+
+ using var rootActivity = activitySource.StartActivity("root", ActivityKind.Client);
+
+ Assert.NotNull(rootActivity);
+
+ rootActivity.SetTag(SemanticConventions.AttributeHttpHost, "opentelemetry.io");
+
+ var otlpSpan = rootActivity.ToOtlpSpan(DefaultSdkLimitOptions);
+
+ Assert.NotNull(otlpSpan);
+
+ var peerService = otlpSpan.Attributes.FirstOrDefault(kvp => kvp.Key == SemanticConventions.AttributePeerService);
+
+ Assert.NotNull(peerService);
+ Assert.Equal("opentelemetry.io", peerService.Value.StringValue);
+ }
+
+ [Fact]
+ public void UseOpenTelemetryProtocolActivityExporterWithCustomActivityProcessor()
+ {
+ const string ActivitySourceName = "otlp.test";
+ TestActivityProcessor testActivityProcessor = new TestActivityProcessor();
+
+ bool startCalled = false;
+ bool endCalled = false;
+
+ testActivityProcessor.StartAction =
+ (a) =>
+ {
+ startCalled = true;
+ };
+
+ testActivityProcessor.EndAction =
+ (a) =>
+ {
+ endCalled = true;
+ };
+
+ var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .ConfigureServices(services => { services.AddSingleton(configuration); })
+ .AddSource(ActivitySourceName)
+ .AddProcessor(testActivityProcessor)
+ .AddOtlpExporter()
+ .Build();
+
+ using var source = new ActivitySource(ActivitySourceName);
+ var activity = source.StartActivity("Test Otlp Activity");
+ activity?.Stop();
+
+ Assert.True(startCalled);
+ Assert.True(endCalled);
+ }
+
+ [Fact]
+ public void Shutdown_ClientShutdownIsCalled()
+ {
+ var exportClientMock = new TestExportClientCustom();
+
+ var exporterOptions = new OtlpExporterOptions();
+ var transmissionHandler = new OtlpExporterTransmissionHandler(exportClientMock, exporterOptions.TimeoutMilliseconds);
+
+ var exporter = new OtlpTraceExporterNew(new OtlpExporterOptions(), DefaultSdkLimitOptions, DefaultExperimentalOptions, transmissionHandler);
+ exporter.Shutdown();
+
+ Assert.True(exportClientMock.ShutdownCalled);
+ }
+
+ [Fact]
+ public void Null_BatchExportProcessorOptions_SupportedTest()
+ {
+ Sdk.CreateTracerProviderBuilder()
+ .ConfigureServices(services => { services.AddSingleton(configuration); })
+ .AddOtlpExporter(
+ o =>
+ {
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ o.ExportProcessorType = ExportProcessorType.Batch;
+ o.BatchExportProcessorOptions = null!;
+ });
+ }
+
+ [Fact]
+ public void NonnamedOptionsMutateSharedInstanceTest()
+ {
+ var testOptionsInstance = new OtlpExporterOptions();
+
+ OtlpExporterOptions? tracerOptions = null;
+ OtlpExporterOptions? meterOptions = null;
+
+ var services = new ServiceCollection();
+
+ services.AddSingleton(configuration);
+ services.AddOpenTelemetry()
+ .WithTracing(builder => builder.AddOtlpExporter(o =>
+ {
+ Assert.Equal(testOptionsInstance.Endpoint, o.Endpoint);
+
+ tracerOptions = o;
+ o.Endpoint = new("http://localhost/traces");
+ }))
+ .WithMetrics(builder => builder.AddOtlpExporter(o =>
+ {
+ Assert.Equal(testOptionsInstance.Endpoint, o.Endpoint);
+
+ meterOptions = o;
+ o.Endpoint = new("http://localhost/metrics");
+ }));
+
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var tracerProvider = serviceProvider.GetRequiredService();
+
+ // Verify the OtlpTraceExporter saw the correct endpoint.
+
+ Assert.NotNull(tracerOptions);
+ Assert.Null(meterOptions);
+ Assert.Equal("http://localhost/traces", tracerOptions.Endpoint.OriginalString);
+
+ var meterProvider = serviceProvider.GetRequiredService();
+
+ // Verify the OtlpMetricExporter saw the correct endpoint.
+
+ Assert.NotNull(tracerOptions);
+ Assert.NotNull(meterOptions);
+ Assert.Equal("http://localhost/metrics", meterOptions.Endpoint.OriginalString);
+
+ Assert.False(ReferenceEquals(tracerOptions, meterOptions));
+ }
+
+ [Fact]
+ public void NamedOptionsMutateSeparateInstancesTest()
+ {
+ OtlpExporterOptions? tracerOptions = null;
+ OtlpExporterOptions? meterOptions = null;
+
+ var services = new ServiceCollection();
+
+ services.AddSingleton(configuration);
+ services.AddOpenTelemetry()
+ .WithTracing(builder => builder.AddOtlpExporter("Trace", o =>
+ {
+ tracerOptions = o;
+ o.Endpoint = new("http://localhost/traces");
+ }))
+ .WithMetrics(builder => builder.AddOtlpExporter("Metrics", o =>
+ {
+ meterOptions = o;
+ o.Endpoint = new("http://localhost/metrics");
+ }));
+
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var tracerProvider = serviceProvider.GetRequiredService();
+
+ // Verify the OtlpTraceExporter saw the correct endpoint.
+
+ Assert.NotNull(tracerOptions);
+ Assert.Null(meterOptions);
+ Assert.Equal("http://localhost/traces", tracerOptions.Endpoint.OriginalString);
+
+ var meterProvider = serviceProvider.GetRequiredService();
+
+ // Verify the OtlpMetricExporter saw the correct endpoint.
+
+ Assert.NotNull(tracerOptions);
+ Assert.NotNull(meterOptions);
+ Assert.Equal("http://localhost/metrics", meterOptions.Endpoint.OriginalString);
+
+ // Verify expected state of instances.
+
+ Assert.False(ReferenceEquals(tracerOptions, meterOptions));
+ Assert.Equal("http://localhost/traces", tracerOptions.Endpoint.OriginalString);
+ Assert.Equal("http://localhost/metrics", meterOptions.Endpoint.OriginalString);
+ }
+
+ [Theory]
+ [InlineData(true, true)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ public void SpanFlagsTest(bool isRecorded, bool isRemote)
+ {
+ using var activitySource = new ActivitySource(nameof(this.SpanFlagsTest));
+
+ ActivityContext ctx = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ isRecorded ? ActivityTraceFlags.Recorded : ActivityTraceFlags.None,
+ isRemote: isRemote);
+
+ using var rootActivity = activitySource.StartActivity("root", ActivityKind.Server, ctx);
+ Assert.NotNull(rootActivity);
+ var otlpSpan = GetOtlpSpan(rootActivity);
+
+ var flags = (OtlpTrace.SpanFlags)otlpSpan.Flags;
+
+ ActivityTraceFlags traceFlags = (ActivityTraceFlags)(flags & OtlpTrace.SpanFlags.TraceFlagsMask);
+
+ if (isRecorded)
+ {
+ Assert.True(traceFlags.HasFlag(ActivityTraceFlags.Recorded));
+ }
+ else
+ {
+ Assert.False(traceFlags.HasFlag(ActivityTraceFlags.Recorded));
+ }
+
+ Assert.True(flags.HasFlag(OtlpTrace.SpanFlags.ContextHasIsRemoteMask));
+
+ if (isRemote)
+ {
+ Assert.True(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask));
+ }
+ else
+ {
+ Assert.False(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask));
+ }
+ }
+
+ [Theory]
+ [InlineData(true, true)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ public void SpanLinkFlagsTest(bool isRecorded, bool isRemote)
+ {
+ using var activitySource = new ActivitySource(nameof(this.SpanLinkFlagsTest));
+
+ ActivityContext ctx = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ isRecorded ? ActivityTraceFlags.Recorded : ActivityTraceFlags.None,
+ isRemote: isRemote);
+
+ var links = new[]
+ {
+ new ActivityLink(ctx),
+ };
+
+ using var rootActivity = activitySource.StartActivity("root", ActivityKind.Server, default(ActivityContext), links: links);
+
+ Assert.NotNull(rootActivity);
+
+ var otlpSpan = GetOtlpSpan(rootActivity);
+
+ var spanLink = Assert.Single(otlpSpan.Links);
+
+ var flags = (OtlpTrace.SpanFlags)spanLink.Flags;
+
+ ActivityTraceFlags traceFlags = (ActivityTraceFlags)(flags & OtlpTrace.SpanFlags.TraceFlagsMask);
+
+ if (isRecorded)
+ {
+ Assert.True(traceFlags.HasFlag(ActivityTraceFlags.Recorded));
+ }
+ else
+ {
+ Assert.False(traceFlags.HasFlag(ActivityTraceFlags.Recorded));
+ }
+
+ Assert.True(flags.HasFlag(OtlpTrace.SpanFlags.ContextHasIsRemoteMask));
+
+ if (isRemote)
+ {
+ Assert.True(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask));
+ }
+ else
+ {
+ Assert.False(flags.HasFlag(OtlpTrace.SpanFlags.ContextIsRemoteMask));
+ }
+ }
+
+ private static OtlpTrace.Span GetOtlpSpan(Activity activity, SdkLimitOptions? sdkLimitOptions = null)
+ {
+ var request = new OtlpCollector.ExportTraceServiceRequest();
+
+ var activitySerializer = new ActivitySerializer(sdkLimitOptions ?? DefaultSdkLimitOptions);
+
+ byte[] buffer = new byte[10000];
+
+ var batch = new Batch(new[] { activity }, 1);
+
+ var cursor = activitySerializer.Serialize(ref buffer, 0, Resource.Empty, batch);
+
+ var requestArray = new byte[cursor];
+
+ Buffer.BlockCopy(buffer, 0, requestArray, 0, cursor);
+
+ request.MergeFrom(requestArray);
+
+ Assert.NotNull(request);
+
+ var resourceSpans = request.ResourceSpans;
+ Assert.Single(resourceSpans);
+
+ var scopeSpans = resourceSpans[0].ScopeSpans;
+ Assert.Single(scopeSpans);
+
+ var spans = scopeSpans[0].Spans;
+ Assert.Single(spans);
+
+ return spans[0];
+ }
+}
diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/TestExportClientCustom.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/TestExportClientCustom.cs
new file mode 100644
index 00000000000..ee6c91f5fa6
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/TestExportClientCustom.cs
@@ -0,0 +1,49 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if NETFRAMEWORK
+using System.Net.Http;
+#endif
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Custom.ExportClient;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient;
+
+namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests;
+
+internal class TestExportClientCustom(bool throwException = false) : IExportClient
+{
+ public bool SendExportRequestCalled { get; private set; }
+
+ public bool ShutdownCalled { get; private set; }
+
+ public bool ThrowException { get; set; } = throwException;
+
+ public HttpRequestMessage CreateHttpRequest(byte[] request, int contentLength)
+ {
+ throw new NotImplementedException();
+ }
+
+ public ExportClientResponse SendExportRequest(byte[] request, int contentLenght, DateTime deadlineUtc, CancellationToken cancellationToken = default)
+ {
+ if (this.ThrowException)
+ {
+ throw new Exception("Exception thrown from SendExportRequest");
+ }
+
+ this.SendExportRequestCalled = true;
+ return new TestExportClientResponse(true, deadlineUtc, null);
+ }
+
+ public bool Shutdown(int timeoutMilliseconds)
+ {
+ this.ShutdownCalled = true;
+ return true;
+ }
+
+ private class TestExportClientResponse : ExportClientResponse
+ {
+ public TestExportClientResponse(bool success, DateTime deadline, Exception? exception)
+ : base(success, deadline, exception)
+ {
+ }
+ }
+}