Open sandboxFocusImprove this doc

Call telemetry example, step 2: Producing trace spans for SDK calls

Distributed tracing shows how much time an application spends in each call to an external service. The .NET API for tracing is System.Diagnostics.ActivitySource. Each Activity that it starts is a span, which OpenTelemetry exports to a tracing back-end.

In this example, the application calls PaymentClient, a class of a separate class library that represents a third-party SDK. The SDK does not produce spans. A project fabric creates a span around each call to the methods of PaymentClient. The calling code does not change in the source code:

Source Code
1using Metalama.Samples.CallTelemetry.Payments;
2
3namespace Metalama.Samples.CallTelemetry2;
4
5public class CheckoutService
6{
7    private readonly PaymentClient _payments = new();
8
9    public async Task<string> CheckoutAsync(
10        string cardNumber,
11        decimal amount,
12        CancellationToken cancellationToken = default )
13    {
14        var fee = this._payments.GetFee( amount );
15        var authorizationId = await this._payments.AuthorizeAsync( cardNumber, amount + fee, cancellationToken );
16        await this._payments.CaptureAsync( authorizationId, cancellationToken );
17
18        return authorizationId;
19    }
20}
21
Transformed Code
1using Metalama.Samples.CallTelemetry.Payments;
2
3namespace Metalama.Samples.CallTelemetry2;
4
5public class CheckoutService
6{
7    private readonly PaymentClient _payments = new();
8
9    public async Task<string> CheckoutAsync(
10        string cardNumber,
11        decimal amount,
12        CancellationToken cancellationToken = default )
13    {
14        var fee = PaymentsTelemetry.PaymentClient_GetFee_Interceptor( this._payments,amount:amount ,operation:"CheckoutService.CheckoutAsync");
15        var authorizationId = await PaymentsTelemetry.PaymentClient_AuthorizeAsync_Interceptor( this._payments,cardNumber:cardNumber,amount:amount + fee,cancellationToken:cancellationToken ,operation:"CheckoutService.CheckoutAsync");
16        await PaymentsTelemetry.PaymentClient_CaptureAsync_Interceptor( this._payments,authorizationId:authorizationId,cancellationToken:cancellationToken ,operation:"CheckoutService.CheckoutAsync");
17
18        return authorizationId;
19    }
20}
21

The program writes each span to the console when the span ends. The durations change from one run to another.

[span] PaymentClient.GetFee, caller: PriceList.GetPriceWithFee, status: Unset, duration: 1.4 ms
Price with fee: 103.20
[span] PaymentClient.GetFee, caller: CheckoutService.CheckoutAsync, status: Unset, duration: 0.0 ms
[span] PaymentClient.AuthorizeAsync, caller: CheckoutService.CheckoutAsync, status: Unset, duration: 23.4 ms
[span] PaymentClient.CaptureAsync, caller: CheckoutService.CheckoutAsync, status: Unset, duration: 10.5 ms
Authorization: AUTH-1111-103.20
[span] PaymentClient.GetFee, caller: CheckoutService.CheckoutAsync, status: Unset, duration: 0.0 ms
[span] PaymentClient.AuthorizeAsync, caller: CheckoutService.CheckoutAsync, status: Error, duration: 21.1 ms
Checkout failed: The card was declined.

A real application does not use an ActivityListener. Instead, it configures OpenTelemetry to export the spans of the Metalama.Samples.CallTelemetry.Payments source, for instance with AddSource( "Metalama.Samples.CallTelemetry.Payments" ).

Implementation

The PaymentsTelemetry class holds the ActivitySource:

1using System.Diagnostics;
2
3namespace Metalama.Samples.CallTelemetry2;
4
5// Holds the ActivitySource of the calls to the payment SDK. The fabric generates the interceptors in this class.
6internal static class PaymentsTelemetry
7{
8    public const string Name = "Metalama.Samples.CallTelemetry.Payments";
9
10    public static readonly ActivitySource ActivitySource = new( Name );
11}
12

The following project fabric registers the interceptors:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Fabrics;
5using Metalama.Samples.CallTelemetry.Payments;
6using System.Diagnostics;
7
8namespace Metalama.Samples.CallTelemetry2;
9
10// Creates a trace span around each call to the payment SDK.
11internal class TelemetryFabric : ProjectFabric
12{
13    public override void AmendProject( IProjectAmender amender )
14        => amender.InterceptMethods(
15            calls => calls
16                .Type( typeof(PaymentClient) )
17                .AllMethods()
18                .RedirectToSynthesizedMethod( nameof(this.Trace) )
19                .PlaceInType( typeof(PaymentsTelemetry) )
20                .Configure(
21                    method =>
22                    {
23                        // Each call site passes the name of the calling operation, so that the call sites of a
24                        // method share the same interceptor.
25                        var caller = method.Context.Caller.Member;
26                        var operation = method.Method.AddParameter( "operation", typeof(string) );
27                        method.SetArgument( operation, (TypedConstant) $"{caller.DeclaringType.Name}.{caller.Name}" );
28                    } ) );
29
30    [Template]
31    public dynamic? Trace( string operation )
32    {
33        var interceptedMethod = meta.Interception.Method.InterceptedMethod;
34
35        // The span covers the call. For an async method, meta.Proceed() awaits the call.
36        using Activity? activity = meta.ThisType.ActivitySource.StartActivity(
37            $"{interceptedMethod.DeclaringType.Name}.{interceptedMethod.Name}" );
38
39        activity?.SetTag( "code.caller", operation );
40
41        try
42        {
43            return meta.Proceed();
44        }
45        catch ( Exception e )
46        {
47            activity?.SetStatus( ActivityStatusCode.Error, e.Message );
48
49            throw;
50        }
51    }
52}
53

The AmendProject method registers the interceptors with the InterceptMethods method:

  • Type( typeof(PaymentClient) ).AllMethods() selects all the methods that PaymentClient declares.
  • RedirectToSynthesizedMethod( nameof(this.Trace) ) generates the interceptor methods from the Trace template.
  • PlaceInType( typeof(PaymentsTelemetry) ) generates the interceptor methods in the PaymentsTelemetry class, instead of the default MetalamaInterceptors class. The template can then access the ActivitySource field through meta.ThisType.
  • Configure adds the operation parameter to each generated method, and gives its value for each call site: the name of the calling type and member, read from method.Context.Caller.

Sharing the interceptors between call sites

The template needs the name of the calling operation. It could read this name at compile time with meta.Interception.CallingMember, after WithGranularity( InterceptorGranularity.Member ). Metalama would then generate one interceptor for each calling member, because the code of each interceptor would contain a different name.

Instead, the name is a run-time parameter of the template, operation, and each call site passes its value. The code of the interceptor is then the same for all the call sites of a method, and Metalama generates a single interceptor for each method of PaymentClient. For instance, PriceList.GetPriceWithFee and CheckoutService.CheckoutAsync call the same PaymentClient_GetFee_Interceptor method.

Async methods

AuthorizeAsync and CaptureAsync return a Task. For these methods, Metalama generates an async interceptor, and meta.Proceed() awaits the call. The span therefore ends when the asynchronous operation completes, not when the method returns the task. The template does not need specific code for async methods.

When the call throws an exception, the template sets the status of the span to Error, then rethrows the exception.