File: Backchannel\ProfilingJsonRpcExtensions.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.CompilerServices;
using Aspire.Cli.Telemetry;
using StreamJsonRpc;
 
namespace Aspire.Cli.Backchannel;
 
/// <summary>
/// Adds profiling spans and trace-context metadata around StreamJsonRpc calls.
/// </summary>
/// <remarks>
/// The JSON-RPC connection carries W3C traceparent/tracestate via StreamJsonRpc's request
/// envelope support. These helpers wrap client calls and inject extra trace metadata, such as
/// baggage, into request-object RPC parameters.
/// </remarks>
internal static class ProfilingJsonRpcExtensions
{
    /// <summary>
    /// Controls when the client span for a streaming RPC call ends.
    /// </summary>
    internal enum StreamingSpanLifetime
    {
        /// <summary>
        /// The span stays open for the entire enumeration and is disposed when the stream
        /// completes (or faults). Use when the span is meant to represent the full streaming
        /// lifetime - for example, a backchannel that streams resource updates for as long
        /// as the AppHost runs.
        /// </summary>
        Enumeration,
 
        /// <summary>
        /// The span ends when the first stream item arrives. Use for setup-style RPCs where
        /// the meaningful work is producing the first response and the rest of the stream is
        /// long-lived but not interesting for timing (otherwise it dominates duration views).
        /// </summary>
        FirstItem
    }
 
    public static async Task InvokeWithProfilingAsync(
        this JsonRpc rpc,
        ProfilingTelemetry? profilingTelemetry,
        string connectionName,
        string methodName,
        object?[] arguments,
        CancellationToken cancellationToken)
    {
        using var activity = profilingTelemetry?.StartJsonRpcClientCall(connectionName, methodName, streaming: false) ?? default;
        arguments = WithTraceContext(arguments, activity.CreateBackchannelTraceContext());
 
        try
        {
            await rpc.InvokeWithCancellationAsync(methodName, arguments, cancellationToken).ConfigureAwait(false);
            activity.AddJsonRpcResponseReceivedEvent();
        }
        catch (Exception ex)
        {
            activity.SetError(ex);
            throw;
        }
    }
 
    public static async Task<T> InvokeWithProfilingAsync<T>(
        this JsonRpc rpc,
        ProfilingTelemetry? profilingTelemetry,
        string connectionName,
        string methodName,
        object?[] arguments,
        CancellationToken cancellationToken)
    {
        using var activity = profilingTelemetry?.StartJsonRpcClientCall(connectionName, methodName, streaming: false) ?? default;
        arguments = WithTraceContext(arguments, activity.CreateBackchannelTraceContext());
 
        try
        {
            var response = await rpc.InvokeWithCancellationAsync<T>(methodName, arguments, cancellationToken).ConfigureAwait(false);
            activity.AddJsonRpcResponseReceivedEvent();
            return response;
        }
        catch (Exception ex)
        {
            activity.SetError(ex);
            throw;
        }
    }
 
    public static async Task<IAsyncEnumerable<T>> InvokeStreamingWithProfilingAsync<T>(
        this JsonRpc rpc,
        ProfilingTelemetry? profilingTelemetry,
        string connectionName,
        string methodName,
        object?[] arguments,
        CancellationToken cancellationToken,
        StreamingSpanLifetime spanLifetime = StreamingSpanLifetime.Enumeration)
    {
        // Do not use `using` here: for a non-null response, activity ownership
        // transfers to the returned enumerable. If a caller obtains the enumerable
        // but never enumerates it, EnumerateWithProfiling will not dispose the activity.
        var activity = profilingTelemetry?.StartJsonRpcClientCall(connectionName, methodName, streaming: true) ?? default;
        arguments = WithTraceContext(arguments, activity.CreateBackchannelTraceContext());
 
        try
        {
            var response = await rpc.InvokeWithCancellationAsync<IAsyncEnumerable<T>>(methodName, arguments, cancellationToken).ConfigureAwait(false);
            activity.AddJsonRpcResponseReceivedEvent();
 
            return EnumerateWithProfiling(response, activity, spanLifetime, cancellationToken);
        }
        catch (Exception ex)
        {
            activity.SetError(ex);
            activity.Dispose();
            throw;
        }
    }
 
    private static async IAsyncEnumerable<T> EnumerateWithProfiling<T>(
        IAsyncEnumerable<T> response,
        ProfilingTelemetry.ActivityScope activity,
        StreamingSpanLifetime spanLifetime,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        // StreamJsonRpc returns the IAsyncEnumerable before any stream items are read. Long-lived
        // startup streams can outlive readiness and dominate duration views, so callers that only
        // need setup timing can stop the client span as soon as the first item arrives.
        var itemCount = 0;
        var activityDisposed = false;
        var enumerator = response.GetAsyncEnumerator(cancellationToken);
        try
        {
            while (true)
            {
                T item;
                try
                {
                    if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
                    {
                        break;
                    }
 
                    item = enumerator.Current;
                }
                catch (Exception ex)
                {
                    if (!activityDisposed)
                    {
                        activity.SetError(ex);
                    }
 
                    throw;
                }
 
                if (itemCount == 0)
                {
                    activity.AddJsonRpcStreamFirstItemEvent();
                    if (spanLifetime == StreamingSpanLifetime.FirstItem)
                    {
                        activity.Dispose();
                        activityDisposed = true;
                    }
                }
 
                itemCount++;
                yield return item;
            }
 
            if (!activityDisposed)
            {
                activity.AddJsonRpcStreamCompletedEvent();
            }
        }
        finally
        {
            await enumerator.DisposeAsync().ConfigureAwait(false);
            if (!activityDisposed)
            {
                activity.SetJsonRpcStreamItemCount(itemCount);
                activity.Dispose();
            }
        }
    }
 
    private static object?[] WithTraceContext(object?[] arguments, BackchannelTraceContext? traceContext)
    {
        if (traceContext is null || arguments.Length != 1)
        {
            return arguments;
        }
 
        // StreamJsonRpc accepts RPC parameters as an object array. The auxiliary backchannel
        // contract uses a single request object parameter, so replace that one argument with
        // a copy carrying trace metadata instead of mutating the caller's instance.
        return arguments[0] is BackchannelRequest request
            ? [request.WithTraceContext(traceContext)]
            : arguments;
    }
 
}