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) + { + } + } +}