diff --git a/Google.Api.Gax.Grpc.Tests/ApiBidirectionalStreamingCallTest.cs b/Google.Api.Gax.Grpc.Tests/ApiBidirectionalStreamingCallTest.cs index edce728b..15361970 100644 --- a/Google.Api.Gax.Grpc.Tests/ApiBidirectionalStreamingCallTest.cs +++ b/Google.Api.Gax.Grpc.Tests/ApiBidirectionalStreamingCallTest.cs @@ -7,6 +7,8 @@ using Google.Api.Gax.Testing; using System; +using System.Diagnostics; +using System.Linq; using Xunit; namespace Google.Api.Gax.Grpc.Tests @@ -17,7 +19,7 @@ public class ApiBidirectionalStreamingCallTest public void FailWithRetry() { var apiCall = ApiBidirectionalStreamingCall.Create( - "Method", + "Method", callOptions => null, CallSettings.FromRetry(new RetrySettings(5, TimeSpan.Zero, TimeSpan.Zero, 1.0, e => false, RetrySettings.RandomJitter)), new BidirectionalStreamingSettings(100), @@ -52,5 +54,33 @@ public void WithLogging() var entry = Assert.Single(logger.ListLogEntries()); Assert.Contains("BidiStreamingMethod", entry.Message); } + + [Fact] + public void WithTracing() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var apiCall = ApiBidirectionalStreamingCall.Create( + "BidiStreamingMethod", + callOptions => null, + null, + new BidirectionalStreamingSettings(100), + new FakeClock()).WithTracing(source); + apiCall.Call(null); + Assert.NotNull(capturedActivity); + Assert.Equal(ApiCallTracingExtensions.BidiStreamingCallType, capturedActivity.Tags.First(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Equal(sourceName, capturedActivity.Source.Name); + Assert.Contains("BidiStreamingMethod", capturedActivity.OperationName); + } } } diff --git a/Google.Api.Gax.Grpc.Tests/ApiCallTest.cs b/Google.Api.Gax.Grpc.Tests/ApiCallTest.cs index 60f49996..2a13b675 100644 --- a/Google.Api.Gax.Grpc.Tests/ApiCallTest.cs +++ b/Google.Api.Gax.Grpc.Tests/ApiCallTest.cs @@ -9,6 +9,8 @@ using Google.Protobuf.Reflection; using Grpc.Core; using System; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -217,6 +219,60 @@ public async Task WithLogging_Async() Assert.All(entries, entry => Assert.Contains("SimpleMethod", entry.Message)); } + [Fact] + public void WithTracing_Sync() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var call = new ApiCall( + "SimpleMethod", + (req, cs) => Task.FromResult(default(SimpleResponse)), + (req, cs) => null, + null).WithTracing(source); + call.Sync(new SimpleRequest(), null); + Assert.NotNull(capturedActivity); + Assert.Contains("SimpleMethod", capturedActivity.OperationName); + Assert.Equal(ApiCallTracingExtensions.UnaryCallType, capturedActivity.Tags.First(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Equal(sourceName, capturedActivity.Source.Name); + } + + [Fact] + public async Task WithTracing_Async() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var call = new ApiCall( + "SimpleMethod", + (req, cs) => Task.FromResult(default(SimpleResponse)), + (req, cs) => null, + null).WithTracing(source); + await call.Async(new SimpleRequest(), null); + Assert.NotNull(capturedActivity); + Assert.Contains("SimpleMethod", capturedActivity.OperationName); + Assert.Equal(ApiCallTracingExtensions.UnaryCallType, capturedActivity.Tags.First(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Equal(sourceName, capturedActivity.Source.Name); + } + internal class ExtractedRequestParamRequest : IMessage { public string TableName { get; set; } diff --git a/Google.Api.Gax.Grpc.Tests/ApiClientStreamingCallTest.cs b/Google.Api.Gax.Grpc.Tests/ApiClientStreamingCallTest.cs index f3145c66..4e7a4b55 100644 --- a/Google.Api.Gax.Grpc.Tests/ApiClientStreamingCallTest.cs +++ b/Google.Api.Gax.Grpc.Tests/ApiClientStreamingCallTest.cs @@ -7,6 +7,8 @@ using Google.Api.Gax.Testing; using System; +using System.Diagnostics; +using System.Linq; using Xunit; namespace Google.Api.Gax.Grpc.Tests @@ -52,5 +54,33 @@ public void WithLogging() var entry = Assert.Single(logger.ListLogEntries()); Assert.Contains("ClientStreamingMethod", entry.Message); } + + [Fact] + public void WithTracing() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var apiCall = ApiClientStreamingCall.Create( + "ClientStreamingMethod", + callOptions => null, + null, + new ClientStreamingSettings(100), + new FakeClock()).WithTracing(source); + apiCall.Call(null); + Assert.NotNull(capturedActivity); + Assert.Equal(ApiCallTracingExtensions.ClientStreamingCallType, capturedActivity.Tags.First(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Contains("ClientStreamingMethod", capturedActivity.OperationName); + Assert.Equal(sourceName, capturedActivity.Source.Name); + } } } diff --git a/Google.Api.Gax.Grpc.Tests/ApiServerStreamingCallTest.cs b/Google.Api.Gax.Grpc.Tests/ApiServerStreamingCallTest.cs index da7b6f40..6f397dd7 100644 --- a/Google.Api.Gax.Grpc.Tests/ApiServerStreamingCallTest.cs +++ b/Google.Api.Gax.Grpc.Tests/ApiServerStreamingCallTest.cs @@ -8,6 +8,8 @@ using Google.Api.Gax.Testing; using Grpc.Core; using System; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -172,5 +174,59 @@ public async Task WithLogging_Async() Assert.Equal(2, entries.Count); Assert.All(entries, entry => Assert.Contains("SimpleMethod", entry.Message)); } + + [Fact] + public void WithTracing_Sync() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var call = new ApiServerStreamingCall( + "SimpleMethod", + (req, cs) => Task.FromResult(default(AsyncServerStreamingCall)), + (req, cs) => null, + null).WithTracing(source); + call.Call(new SimpleRequest(), null); + Assert.NotNull(capturedActivity); + Assert.Contains("SimpleMethod", capturedActivity.OperationName); + Assert.Equal(ApiCallTracingExtensions.ServerStreamingCallType, capturedActivity.Tags.First(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Equal(sourceName, capturedActivity.Source.Name); + } + + [Fact] + public async Task WithTracing_Async() + { + var sourceName = "source"; + Activity capturedActivity = null; + + // We need a listener attached to an ActivitySource, otherwise activity won't be created. + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => capturedActivity = activity + }; + ActivitySource.AddActivityListener(listener); + using var source = new ActivitySource(sourceName); + var call = new ApiServerStreamingCall( + "SimpleMethod", + (req, cs) => Task.FromResult(default(AsyncServerStreamingCall)), + (req, cs) => null, + null).WithTracing(source); + await call.CallAsync(new SimpleRequest(), null); + Assert.NotNull(capturedActivity); + Assert.Contains("SimpleMethod", capturedActivity.OperationName); + Assert.Equal(ApiCallTracingExtensions.ServerStreamingCallType, capturedActivity.Tags.FirstOrDefault(j => j.Key == ApiCallTracingExtensions.GrpcCallTypeTag).Value); + Assert.Equal(sourceName, capturedActivity.Source.Name); + } } } diff --git a/Google.Api.Gax.Grpc/ApiBidirectionalStreamingCall.cs b/Google.Api.Gax.Grpc/ApiBidirectionalStreamingCall.cs index ce199bf6..f1d250a1 100644 --- a/Google.Api.Gax.Grpc/ApiBidirectionalStreamingCall.cs +++ b/Google.Api.Gax.Grpc/ApiBidirectionalStreamingCall.cs @@ -8,6 +8,7 @@ using Grpc.Core; using Microsoft.Extensions.Logging; using System; +using System.Diagnostics; namespace Google.Api.Gax.Grpc { @@ -82,6 +83,10 @@ internal ApiBidirectionalStreamingCall WithLogging(ILogger logger is null ? this : new ApiBidirectionalStreamingCall(_methodName, _call.WithLogging(logger, _methodName), BaseCallSettings, StreamingSettings); - + + internal ApiBidirectionalStreamingCall WithTracing(ActivitySource source) => + source is null + ? this + : new ApiBidirectionalStreamingCall(_methodName, _call.WithTracing(source, _methodName), BaseCallSettings, StreamingSettings); } } diff --git a/Google.Api.Gax.Grpc/ApiCall.cs b/Google.Api.Gax.Grpc/ApiCall.cs index 9ac53b42..0f5c3acb 100644 --- a/Google.Api.Gax.Grpc/ApiCall.cs +++ b/Google.Api.Gax.Grpc/ApiCall.cs @@ -9,6 +9,7 @@ using Grpc.Core; using Microsoft.Extensions.Logging; using System; +using System.Diagnostics; using System.Threading.Tasks; namespace Google.Api.Gax.Grpc @@ -158,6 +159,7 @@ internal ApiCall WithMergedBaseCallSettings(CallSettings ca // Note: it seems unfortunate that we can't reuse whatever logger is already configured here, but we don't // have access to it. This is the logger for logging "I'm about to back off / retry" rather than the actual calls. + // TODO: Check whether ActivitySource is needed or since it is called later we don't need it. internal ApiCall WithRetry(IClock clock, IScheduler scheduler, ILogger retryLogger) => new ApiCall( _methodName, @@ -173,6 +175,14 @@ logger is null _syncCall.WithLogging(logger, _methodName), BaseCallSettings); + internal ApiCall WithTracing(ActivitySource source) => + source is null + ? this + : new ApiCall( + _methodName, _asyncCall.WithTracing(source, _methodName), + _syncCall.WithTracing(source, _methodName), + BaseCallSettings); + /// /// Constructs a new that applies an overlay to /// the underlying . If a value exists in both the original and diff --git a/Google.Api.Gax.Grpc/ApiCallTracingExtensions.cs b/Google.Api.Gax.Grpc/ApiCallTracingExtensions.cs new file mode 100644 index 00000000..307dda92 --- /dev/null +++ b/Google.Api.Gax.Grpc/ApiCallTracingExtensions.cs @@ -0,0 +1,106 @@ +/* + * Copyright 2023 Google Inc. All Rights Reserved. + * Use of this source code is governed by a BSD-style + * license that can be found in the LICENSE file or at + * https://developers.google.com/open-source/licenses/bsd + */ + +using Grpc.Core; +using System; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Google.Api.Gax.Grpc; + +internal static class ApiCallTracingExtensions +{ + internal const string GrpcCallTypeTag = "grpc.call.type"; + internal const string UnaryCallType = "unary"; + internal const string ServerStreamingCallType = "server_streaming"; + internal const string ClientStreamingCallType = "client_streaming"; + internal const string BidiStreamingCallType = "bidi_streaming"; + + // By design, the code is mostly duplicated between the async and sync calls. + + #region Unary calls + // Async call + internal static Func> WithTracing( + this Func> fn, ActivitySource activitySource, string methodName) => + async (request, callSettings) => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, UnaryCallType); + try + { + return await fn(request, callSettings).ConfigureAwait(false); + } + catch (Exception ex) + { + activity?.SetException(ex); + throw; + } + }; + + // Sync call + internal static Func WithTracing( + this Func fn, ActivitySource activitySource, string methodName) => + (request, callSettings) => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, UnaryCallType); + try + { + return fn(request, callSettings); + } + catch (Exception ex) + { + activity?.SetException(ex); + throw; + } + }; + #endregion + + #region Server streaming + // Async call + internal static Func>> WithTracing( + this Func>> fn, ActivitySource activitySource, string methodName) => + async (request, callSettings) => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, ServerStreamingCallType); + return await fn(request, callSettings).ConfigureAwait(false); + }; + + // Sync call + internal static Func> WithTracing( + this Func> fn, ActivitySource activitySource, string methodName) => + (request, callSettings) => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, ServerStreamingCallType); + return fn(request, callSettings); + }; + #endregion + + #region Client streaming + internal static Func> WithTracing( + this Func> fn, ActivitySource activitySource, string methodName) => + callSettings => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, ClientStreamingCallType); + return fn(callSettings); + }; + #endregion + + #region Bidi streaming + internal static Func> WithTracing( + this Func> fn, ActivitySource activitySource, string methodName) => + callSettings => + { + using var activity = activitySource?.StartActivity($"{methodName}/{fn.Method.Name}"); + activity?.SetTag(GrpcCallTypeTag, BidiStreamingCallType); + return fn(callSettings); + }; + #endregion +} diff --git a/Google.Api.Gax.Grpc/ApiClientStreamingCall.cs b/Google.Api.Gax.Grpc/ApiClientStreamingCall.cs index d327c26a..c811d712 100644 --- a/Google.Api.Gax.Grpc/ApiClientStreamingCall.cs +++ b/Google.Api.Gax.Grpc/ApiClientStreamingCall.cs @@ -8,6 +8,7 @@ using Grpc.Core; using Microsoft.Extensions.Logging; using System; +using System.Diagnostics; namespace Google.Api.Gax.Grpc { @@ -80,5 +81,10 @@ internal ApiClientStreamingCall WithLogging(ILogger logger) logger is null ? this : new ApiClientStreamingCall(_methodName, _call.WithLogging(logger, _methodName), BaseCallSettings, StreamingSettings); + + internal ApiClientStreamingCall WithTracing(ActivitySource source) => + source is null + ? this + : new ApiClientStreamingCall(_methodName, _call.WithTracing(source, _methodName), BaseCallSettings, StreamingSettings); } } diff --git a/Google.Api.Gax.Grpc/ApiServerStreamingCall.cs b/Google.Api.Gax.Grpc/ApiServerStreamingCall.cs index 3d37df7d..ee577383 100644 --- a/Google.Api.Gax.Grpc/ApiServerStreamingCall.cs +++ b/Google.Api.Gax.Grpc/ApiServerStreamingCall.cs @@ -8,6 +8,7 @@ using Grpc.Core; using Microsoft.Extensions.Logging; using System; +using System.Diagnostics; using System.Threading.Tasks; namespace Google.Api.Gax.Grpc @@ -144,5 +145,14 @@ logger is null _asyncCall.WithLogging(logger, _methodName), _syncCall.WithLogging(logger, _methodName), BaseCallSettings); + + internal ApiServerStreamingCall WithTracing(ActivitySource source) => + source is null + ? this + : new ApiServerStreamingCall( + _methodName, + _asyncCall.WithTracing(source, _methodName), + _syncCall.WithTracing(source, _methodName), + BaseCallSettings); } } diff --git a/Google.Api.Gax.Grpc/ClientHelper.cs b/Google.Api.Gax.Grpc/ClientHelper.cs index 8d9ea7c3..aacd2464 100644 --- a/Google.Api.Gax.Grpc/ClientHelper.cs +++ b/Google.Api.Gax.Grpc/ClientHelper.cs @@ -9,6 +9,7 @@ using Grpc.Core; using Microsoft.Extensions.Logging; using System; +using System.Diagnostics; namespace Google.Api.Gax.Grpc { @@ -26,10 +27,23 @@ public class ClientHelper /// /// The service settings. /// The logger to use for API calls - public ClientHelper(ServiceSettingsBase settings, ILogger logger) + // TODO: We can make this constructor obsolete. + public ClientHelper(ServiceSettingsBase settings, ILogger logger) : this(settings, logger, null) + { + } + + /// + /// Constructs a helper from the given settings. + /// Behavior is undefined if settings are changed after construction. + /// + /// The service settings. + /// The logger to use for API calls + /// The source to create and start . + public ClientHelper(ServiceSettingsBase settings, ILogger logger, ActivitySource activitySource) { GaxPreconditions.CheckNotNull(settings, nameof(settings)); Logger = logger; + ActivitySource = activitySource; Clock = settings.Clock ?? SystemClock.Instance; Scheduler = settings.Scheduler ?? SystemScheduler.Instance; _clientCallSettings = settings.CallSettings; @@ -55,6 +69,11 @@ public ClientHelper(ServiceSettingsBase settings, ILogger logger) /// public ILogger Logger { get; } + /// + /// The ActivitySource used by this instance, or null if it does not perform tracing. + /// + public ActivitySource ActivitySource { get; } + /// /// Builds an given suitable underlying async and sync calls. /// @@ -77,9 +96,10 @@ public ApiCall BuildApiCall( // These operations are applied in reverse order. // I.e. Version header is added first, then retry is performed. return ApiCall.Create(methodName, asyncGrpcCall, syncGrpcCall, baseCallSettings, Clock) - .WithLogging(Logger) + .WithTracing(ActivitySource) + .WithLogging(Logger) .WithRetry(Clock, Scheduler, Logger) - .WithMergedBaseCallSettings(_versionCallSettings); + .WithMergedBaseCallSettings(_versionCallSettings); } /// @@ -101,6 +121,7 @@ public ApiServerStreamingCall BuildApiCall BuildApiCall BuildApiCall +/// The extension methods for Activity. +/// +public static class ActivityExtensions +{ + internal const string AttributeExceptionEventName = "exception"; + + internal const string AttributeExceptionType = "exception.type"; + + internal const string AttributeExceptionMessage = "exception.message"; + + internal const string AttributeExceptionStacktrace = "exception.stacktrace"; + + /// + /// Sets the exception as an event. + /// + /// The activity on which the exception is set. + /// The exception object. + public static void SetException(this Activity activity, Exception ex) + { + if (ex is null || activity is null) + { + return; + } + + var tagsCollection = new ActivityTagsCollection + { + { AttributeExceptionType, ex.GetType().FullName }, + { AttributeExceptionStacktrace, ex.ToString() }, + }; + + if (!string.IsNullOrWhiteSpace(ex.Message)) + { + tagsCollection[AttributeExceptionMessage] = ex.Message; + } + + activity.AddEvent(new ActivityEvent(AttributeExceptionEventName, default, tagsCollection)); + } +} diff --git a/Google.Api.Gax/ActivitySourceHelper.cs b/Google.Api.Gax/ActivitySourceHelper.cs new file mode 100644 index 00000000..cbfaf0fd --- /dev/null +++ b/Google.Api.Gax/ActivitySourceHelper.cs @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Google LLC + * Use of this source code is governed by a BSD-style + * license that can be found in the LICENSE file or at + * https://developers.google.com/open-source/licenses/bsd + */ + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; + +namespace Google.Api.Gax; + +/// +/// Helper class for getting the ActivitySource for a specified client type. +/// +public static class ActivitySourceHelper +{ + private static readonly ConcurrentDictionary s_activitySources = new(); + + /// + /// Gets the instance of ActivitySource for the specified client type. + /// + /// The of client for which to get the ActivitySource. + /// The ActivitySource. + /// + /// This method should only be invoked from the generated .NET client libraries and only for a client type. + /// + public static ActivitySource FromClientType(Type type) + { + GaxPreconditions.CheckNotNull(type, nameof(type)); + return GetActivitySource(type); + } + + /// + /// Gets the instance of ActivitySource for the specified client type. + /// + /// The of client for which to get the ActivitySource. + /// The ActivitySource. + /// + /// This method should only be invoked from the generated .NET client libraries and only for a client type. + /// + public static ActivitySource FromClientType() => FromClientType(typeof(T)); + + private static ActivitySource GetActivitySource(Type type) => s_activitySources.GetOrAdd(type, MaybeCreateActivitySource); + + private static ActivitySource MaybeCreateActivitySource(Type type) + { + var assembly = type.Assembly; + var sourceName = type.FullName; + var appVersion = assembly.GetCustomAttribute()?.Version; + return new ActivitySource(sourceName, appVersion); + } +} \ No newline at end of file diff --git a/Google.Api.Gax/Google.Api.Gax.csproj b/Google.Api.Gax/Google.Api.Gax.csproj index 5920f1e8..cfe5c538 100644 --- a/Google.Api.Gax/Google.Api.Gax.csproj +++ b/Google.Api.Gax/Google.Api.Gax.csproj @@ -16,6 +16,7 @@ +