File: Diagnostics\RemoteHostProfilingTelemetry.cs
Web Access
Project: src\src\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj (Aspire.Hosting.RemoteHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Reflection;
using System.Text.Json.Nodes;
using Aspire.TypeSystem;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Hosting.RemoteHost.Diagnostics;
 
internal sealed class RemoteHostProfilingTelemetry(IConfiguration configuration) : IDisposable
{
    public const string ActivitySourceName = "Aspire.Hosting.RemoteHost.Profiling";
 
    public static RemoteHostProfilingTelemetry Disabled { get; } = new(new ConfigurationBuilder().Build());
 
    private readonly ActivitySource _activitySource = new(ActivitySourceName);
 
    internal ActivitySource ActivitySource => _activitySource;
 
    internal static class EnvironmentVariables
    {
        public const string Enabled = KnownConfigNames.ProfilingEnabled;
        public const string SessionId = KnownConfigNames.ProfilingSessionId;
        public const string TraceParent = KnownConfigNames.ProfilingTraceParent;
        public const string TraceState = KnownConfigNames.ProfilingTraceState;
    }
 
    internal static class Baggage
    {
        public const string SessionId = "aspire.profiling.session_id";
    }
 
    internal static class Activities
    {
        // Activity names describe remote AppHost server and RPC work. Keep names stable
        // because profiling exports are queried across CLI and AppHost versions.
        public const string RemoteHostRun = "aspire.hosting.remotehost.run";
        public const string JsonRpcListen = "aspire.hosting.remotehost.jsonrpc.listen";
        public const string JsonRpcConnection = "aspire.hosting.remotehost.jsonrpc.connection";
        public const string JsonRpcServerCall = "aspire.hosting.remotehost.jsonrpc.server";
        public const string AssemblyLoad = "aspire.hosting.remotehost.assembly.load";
        public const string AtsContextCreate = "aspire.hosting.remotehost.ats.context_create";
        public const string CapabilityScan = "aspire.hosting.remotehost.ats.capability_scan";
        public const string CapabilityInvoke = "aspire.hosting.remotehost.capability.invoke";
        public const string CodeGenerationGetCapabilities = "aspire.hosting.remotehost.codegen.get_capabilities";
        public const string CodeGenerationGenerate = "aspire.hosting.remotehost.codegen.generate";
        public const string LanguageDetect = "aspire.hosting.remotehost.language.detect";
        public const string LanguageGetRuntimeSpec = "aspire.hosting.remotehost.language.get_runtime_spec";
        public const string LanguageScaffold = "aspire.hosting.remotehost.language.scaffold";
    }
 
    internal static class Tags
    {
        // Tags capture low-cardinality dimensions and diagnostics for remote-host
        // profiling spans. Avoid raw paths, URLs, tokens, command lines, and raw argument
        // values; capability identity and argument shape are safe enough for profiling.
        public const string ProfilingSessionId = "aspire.profiling.session_id";
        public const string LegacyStartupOperationId = "aspire.startup.operation_id";
        public const string Transport = "aspire.hosting.remotehost.transport";
        public const string ActiveClientCount = "aspire.hosting.remotehost.jsonrpc.active_client_count";
        public const string DisconnectReason = "aspire.hosting.remotehost.jsonrpc.disconnect_reason";
        public const string JsonRpcMethod = "rpc.method";
        public const string JsonRpcStreaming = "aspire.hosting.remotehost.jsonrpc.streaming";
        public const string AuthenticationSucceeded = "aspire.hosting.remotehost.authentication.succeeded";
        public const string AssemblyCount = "aspire.hosting.remotehost.assembly.count";
        public const string AssemblyCacheHit = "aspire.hosting.remotehost.assembly.cache_hit";
        public const string AssemblyRequestedNames = "aspire.hosting.remotehost.assembly.requested_names";
        public const string AssemblyLoadedNames = "aspire.hosting.remotehost.assembly.loaded_names";
        public const string CapabilityScanFirstScan = "aspire.hosting.remotehost.capability_scan.first_scan";
        public const string CapabilityCount = "aspire.hosting.remotehost.capability.count";
        public const string HandleTypeCount = "aspire.hosting.remotehost.handle_type.count";
        public const string DtoTypeCount = "aspire.hosting.remotehost.dto_type.count";
        public const string EnumTypeCount = "aspire.hosting.remotehost.enum_type.count";
        public const string ExportedValueCount = "aspire.hosting.remotehost.exported_value.count";
        public const string DiagnosticCount = "aspire.hosting.remotehost.diagnostic.count";
        public const string CapabilityId = "aspire.hosting.remotehost.capability.id";
        public const string CapabilityPackage = "aspire.hosting.remotehost.capability.package";
        public const string CapabilityKind = "aspire.hosting.remotehost.capability.kind";
        public const string CapabilityArgumentCount = "aspire.hosting.remotehost.capability.argument.count";
        public const string CapabilityArgumentNames = "aspire.hosting.remotehost.capability.argument.names";
        public const string Language = "aspire.hosting.remotehost.language";
        public const string FileCount = "aspire.hosting.remotehost.file.count";
        public const string DetectionMatched = "aspire.hosting.remotehost.language.detection_matched";
        public const string ExceptionType = "exception.type";
        public const string ExceptionMessage = "exception.message";
    }
 
    internal static class Events
    {
        // Events mark meaningful points within longer remote-host spans, such as
        // socket readiness, connection lifecycle, and authentication decisions.
        public const string JsonRpcServerListening = "aspire.hosting.remotehost.jsonrpc.server_listening";
        public const string JsonRpcClientConnected = "aspire.hosting.remotehost.jsonrpc.client_connected";
        public const string JsonRpcListening = "aspire.hosting.remotehost.jsonrpc.listening";
        public const string JsonRpcConnectionClosed = "aspire.hosting.remotehost.jsonrpc.connection_closed";
        public const string AuthenticationAccepted = "aspire.hosting.remotehost.authentication.accepted";
        public const string AuthenticationRejected = "aspire.hosting.remotehost.authentication.rejected";
        public const string Exception = "exception";
    }
 
    internal static class Values
    {
        public const string NamedPipe = "named_pipe";
        public const string UnixDomainSocket = "unix_domain_socket";
    }
 
    public bool IsEnabled => IsProfilingEnabled(configuration);
 
    public static bool IsProfilingEnabled(IConfiguration? configuration)
    {
        return IsTruthy(configuration?[EnvironmentVariables.Enabled]) ||
            IsTruthy(configuration?[KnownConfigNames.Legacy.StartupProfilingEnabled]);
    }
 
    public static bool ShouldConfigureExporter(IConfiguration? configuration)
    {
        if (!IsProfilingEnabled(configuration))
        {
            return false;
        }
 
        return !string.IsNullOrEmpty(configuration?[KnownOtelConfigNames.ExporterOtlpEndpoint]);
    }
 
    public ActivityScope StartRemoteHostRun()
    {
        return StartActivity(Activities.RemoteHostRun);
    }
 
    public ActivityScope StartJsonRpcListen(string transport)
    {
        var activity = StartActivity(Activities.JsonRpcListen);
        activity.SetTransport(transport);
        return activity;
    }
 
    public ActivityScope StartJsonRpcConnection()
    {
        return StartActivity(Activities.JsonRpcConnection);
    }
 
    public ActivityScope StartJsonRpcServerCall(string methodName, bool streaming = false)
    {
        var activity = StartActivity(Activities.JsonRpcServerCall, ActivityKind.Server);
        activity.SetJsonRpcCall(methodName, streaming);
        return activity;
    }
 
    public ActivityScope StartJsonRpcInvokeCapability(string capabilityId, JsonObject? args)
    {
        var activity = StartJsonRpcServerCall("invokeCapability");
        activity.SetCapabilityInvocation(capabilityId, args);
        return activity;
    }
 
    public ActivityScope StartAssemblyLoad(bool cacheHit)
    {
        var activity = StartActivity(Activities.AssemblyLoad);
        activity.SetAssemblyCacheHit(cacheHit);
        return activity;
    }
 
    public ActivityScope StartAtsContextCreate()
    {
        return StartActivity(Activities.AtsContextCreate, preferConfiguredParent: true);
    }
 
    public ActivityScope StartCapabilityScan(int assemblyCount, bool firstScan)
    {
        var activity = StartActivity(Activities.CapabilityScan);
        activity.SetAssemblyCount(assemblyCount);
        activity.SetCapabilityScanFirstScan(firstScan);
        return activity;
    }
 
    public ActivityScope StartCapabilityInvoke(string capabilityId, AtsCapabilityInfo? capability)
    {
        var activity = StartActivity(Activities.CapabilityInvoke);
        activity.SetCapability(capabilityId, capability);
        return activity;
    }
 
    public ActivityScope StartCodeGenerationGetCapabilities()
    {
        return StartActivity(Activities.CodeGenerationGetCapabilities, ActivityKind.Server);
    }
 
    public ActivityScope StartCodeGenerationGenerate(string language)
    {
        var activity = StartActivity(Activities.CodeGenerationGenerate, ActivityKind.Server);
        activity.SetLanguage(language);
        return activity;
    }
 
    public ActivityScope StartLanguageDetect()
    {
        return StartActivity(Activities.LanguageDetect, ActivityKind.Server);
    }
 
    public ActivityScope StartLanguageGetRuntimeSpec(string language)
    {
        var activity = StartActivity(Activities.LanguageGetRuntimeSpec, ActivityKind.Server);
        activity.SetLanguage(language);
        return activity;
    }
 
    public ActivityScope StartLanguageScaffold(string language)
    {
        var activity = StartActivity(Activities.LanguageScaffold, ActivityKind.Server);
        activity.SetLanguage(language);
        return activity;
    }
 
    private ActivityScope StartActivity(
        string name,
        ActivityKind activityKind = ActivityKind.Internal,
        bool preferConfiguredParent = false)
    {
        if (!IsEnabled)
        {
            return default;
        }
 
        var ambientActivity = Activity.Current;
        Activity? activity;
        if (preferConfiguredParent &&
            TryGetConfiguredActivityContext(out var preferredParentContext))
        {
            activity = _activitySource.StartActivity(name, activityKind, preferredParentContext);
        }
        else if (TryGetAmbientRemoteParentContext(ambientActivity, out var ambientRemoteParent))
        {
            // StreamJsonRpc creates an unexported server activity from the caller's
            // traceparent. Parent profiling spans to the remote caller instead so
            // exported CLI and RemoteHost spans are adjacent in the trace.
            activity = _activitySource.StartActivity(name, activityKind, ambientRemoteParent);
        }
        else if ((ambientActivity is null || ambientActivity.Source.Name != ActivitySourceName) &&
            TryGetConfiguredActivityContext(out var parentContext))
        {
            activity = _activitySource.StartActivity(name, activityKind, parentContext);
        }
        else
        {
            activity = _activitySource.StartActivity(name, activityKind);
        }
 
        AddProfilingSession(activity, ambientActivity);
        return new ActivityScope(activity);
    }
 
    private static bool TryGetAmbientRemoteParentContext(Activity? ambientActivity, out ActivityContext parentContext)
    {
        if (ambientActivity is not null &&
            ambientActivity.Source.Name != ActivitySourceName &&
            ambientActivity.Parent is null &&
            ambientActivity.ParentSpanId != default)
        {
            parentContext = new ActivityContext(
                ambientActivity.TraceId,
                ambientActivity.ParentSpanId,
                ambientActivity.ActivityTraceFlags,
                ambientActivity.TraceStateString,
                isRemote: true);
            return true;
        }
 
        parentContext = default;
        return false;
    }
 
    private void AddProfilingSession(Activity? activity, Activity? ambientActivity)
    {
        if (activity is null)
        {
            return;
        }
 
        // Profiling spans can be siblings under StreamJsonRpc's short-lived activities.
        // Seed the ambient ancestor chain with baggage so later profiling siblings reuse
        // the same session after an intermediate parent activity has ended.
        var sessionId = GetProfilingSessionIdFromAncestors(ambientActivity) ?? GetProfilingSessionId(activity) ?? GetConfiguredSessionId() ?? Guid.NewGuid().ToString("N");
        AddProfilingSessionBaggage(ambientActivity, sessionId);
 
        // Keep profiling tags on profiling spans only. Non-profiling ambient activities only
        // carry the session as baggage so it can flow across async and RPC boundaries.
        activity.SetBaggage(Baggage.SessionId, sessionId);
        activity.SetTag(Tags.ProfilingSessionId, sessionId);
        activity.SetTag(Tags.LegacyStartupOperationId, sessionId);
    }
 
    private bool TryGetConfiguredActivityContext(out ActivityContext activityContext)
    {
        var traceParent = GetConfigurationValue(configuration, EnvironmentVariables.TraceParent, KnownConfigNames.Legacy.StartupTraceParent);
        var traceState = GetConfigurationValue(configuration, EnvironmentVariables.TraceState, KnownConfigNames.Legacy.StartupTraceState);
        if (!string.IsNullOrEmpty(traceParent) &&
            ActivityContext.TryParse(traceParent, traceState, out activityContext))
        {
            return true;
        }
 
        activityContext = default;
        return false;
    }
 
    private string? GetConfiguredSessionId()
    {
        return GetConfigurationValue(configuration, EnvironmentVariables.SessionId, KnownConfigNames.Legacy.StartupOperationId);
    }
 
    private static string? GetConfigurationValue(IConfiguration? configuration, string name, string legacyName)
    {
        return configuration?[name] is { Length: > 0 } value ? value : configuration?[legacyName];
    }
 
    private static string? GetProfilingSessionId(Activity? activity)
    {
        return activity?.GetBaggageItem(Baggage.SessionId) is { Length: > 0 } sessionId ? sessionId : null;
    }
 
    private static string? GetProfilingSessionIdFromAncestors(Activity? activity)
    {
        for (var current = activity; current is not null; current = current.Parent)
        {
            if (GetProfilingSessionId(current) is { } sessionId)
            {
                return sessionId;
            }
        }
 
        return null;
    }
 
    private static void AddProfilingSessionBaggage(Activity? activity, string sessionId)
    {
        for (var current = activity; current is not null; current = current.Parent)
        {
            if (GetProfilingSessionId(current) is null)
            {
                current.SetBaggage(Baggage.SessionId, sessionId);
            }
        }
    }
 
    private static bool IsTruthy(string? value)
    {
        return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
    }
 
    public void Dispose()
    {
        _activitySource.Dispose();
    }
 
    internal readonly struct ActivityScope(Activity? activity) : IDisposable
    {
        public bool IsRunning => activity is not null;
 
        public void AddAuthenticationResult(bool authenticated)
        {
            SetTag(Tags.AuthenticationSucceeded, authenticated);
            AddEvent(authenticated ? Events.AuthenticationAccepted : Events.AuthenticationRejected);
        }
 
        public void AddJsonRpcClientConnected(int activeClientCount)
        {
            activity?.AddEvent(new ActivityEvent(Events.JsonRpcClientConnected, tags: new ActivityTagsCollection
            {
                [Tags.ActiveClientCount] = activeClientCount
            }));
        }
 
        public void AddJsonRpcConnectionClosed(string disconnectReason)
        {
            SetTag(Tags.DisconnectReason, disconnectReason);
            AddEvent(Events.JsonRpcConnectionClosed);
        }
 
        public void AddJsonRpcListening() => AddEvent(Events.JsonRpcListening);
 
        public void AddJsonRpcServerListening() => AddEvent(Events.JsonRpcServerListening);
 
        public void SetAssemblyCacheHit(bool cacheHit) => SetTag(Tags.AssemblyCacheHit, cacheHit);
 
        public void SetAssemblyCount(int count) => SetTag(Tags.AssemblyCount, count);
 
        public void SetAssemblyRequestedNames(IReadOnlyList<string> assemblyNames)
        {
            if (activity is null)
            {
                return;
            }
 
            activity.SetTag(Tags.AssemblyRequestedNames, SanitizeAssemblyNames(assemblyNames));
        }
 
        public void SetAssemblyLoadedNames(IReadOnlyList<Assembly> assemblies)
        {
            if (activity is null)
            {
                return;
            }
 
            activity.SetTag(Tags.AssemblyLoadedNames, SanitizeAssemblyNames(assemblies.Select(assembly => assembly.GetName().Name)));
        }
 
        public void SetAtsCounts(int capabilityCount, int handleTypeCount, int dtoTypeCount, int enumTypeCount, int exportedValueCount, int diagnosticCount)
        {
            SetTag(Tags.CapabilityCount, capabilityCount);
            SetTag(Tags.HandleTypeCount, handleTypeCount);
            SetTag(Tags.DtoTypeCount, dtoTypeCount);
            SetTag(Tags.EnumTypeCount, enumTypeCount);
            SetTag(Tags.ExportedValueCount, exportedValueCount);
            SetTag(Tags.DiagnosticCount, diagnosticCount);
        }
 
        public void SetCapability(string capabilityId, AtsCapabilityInfo? capability)
        {
            SetTag(Tags.CapabilityId, capabilityId);
            SetTag(Tags.CapabilityPackage, GetCapabilityPackage(capabilityId));
            SetTag(Tags.CapabilityKind, capability?.CapabilityKind.ToString());
        }
 
        public void SetCapabilityInvocation(string capabilityId, JsonObject? args)
        {
            if (activity is null)
            {
                return;
            }
 
            activity.SetTag(Tags.CapabilityId, capabilityId);
            activity.SetTag(Tags.CapabilityPackage, GetCapabilityPackage(capabilityId));
            activity.SetTag(Tags.CapabilityArgumentCount, args?.Count ?? 0);
            activity.SetTag(Tags.CapabilityArgumentNames, SanitizeArgumentNames(args));
        }
 
        public void SetCapabilityScanFirstScan(bool firstScan) => SetTag(Tags.CapabilityScanFirstScan, firstScan);
 
        public void SetDetectionMatched(bool matched) => SetTag(Tags.DetectionMatched, matched);
 
        public void SetError(Exception exception)
        {
            if (activity is null)
            {
                return;
            }
 
            activity.SetStatus(ActivityStatusCode.Error, exception.Message);
            activity.AddEvent(new ActivityEvent(Events.Exception, tags: new ActivityTagsCollection
            {
                [Tags.ExceptionType] = exception.GetType().FullName,
                [Tags.ExceptionMessage] = exception.Message
            }));
        }
 
        public void SetError(string description) => activity?.SetStatus(ActivityStatusCode.Error, description);
 
        public void SetFileCount(int count) => SetTag(Tags.FileCount, count);
 
        public void SetJsonRpcCall(string methodName, bool streaming)
        {
            SetTag(Tags.JsonRpcMethod, methodName);
            SetTag(Tags.JsonRpcStreaming, streaming);
        }
 
        public void SetLanguage(string? language) => SetTag(Tags.Language, language);
 
        public void SetTransport(string transport) => SetTag(Tags.Transport, transport);
 
        public void Dispose()
        {
            activity?.Dispose();
        }
 
        private void AddEvent(string name) => activity?.AddEvent(new ActivityEvent(name));
 
        private void SetTag(string key, object? value) => activity?.SetTag(key, value);
 
        private static string? GetCapabilityPackage(string capabilityId)
        {
            var separatorIndex = capabilityId.IndexOf('/');
            return separatorIndex > 0 ? capabilityId[..separatorIndex] : null;
        }
 
        private static string[] SanitizeArgumentNames(JsonObject? args)
        {
            return args is null
                ? []
                : [.. args.Select(arg => arg.Key).Order(StringComparer.Ordinal)];
        }
 
        private static string[] SanitizeAssemblyNames(IEnumerable<string?> assemblyNames)
        {
            return [.. assemblyNames
                .Where(name => !string.IsNullOrWhiteSpace(name))
                .Select(name => name!)
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .Order(StringComparer.OrdinalIgnoreCase)];
        }
    }
}