File: Telemetry\DashboardTelemetryService.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
 
namespace Aspire.Dashboard.Telemetry;
 
public sealed class DashboardTelemetryService
{
    private readonly SemaphoreSlim _lock = new SemaphoreSlim(1);
    private readonly ILogger<DashboardTelemetryService> _logger;
    private readonly IDashboardTelemetrySender _telemetrySender;
 
    public DashboardTelemetryService(
        ILogger<DashboardTelemetryService> logger,
        IDashboardTelemetrySender telemetrySender)
    {
        _logger = logger;
        _telemetrySender = telemetrySender;
    }
 
    /// <summary>
    /// Whether the telemetry service has been initialized. This will be true if <see cref="InitializeAsync"/> has completed.
    /// </summary>
    public bool IsTelemetryInitialized => _telemetrySender.State != TelemetrySessionState.Uninitialized;
 
    /// <summary>
    /// Whether telemetry is enabled in the current environment. This will be false if:
    /// <list type="bullet">
    /// <item>The user is not running the Aspire dashboard through a supported IDE version</item>
    /// <item>The dashboard resource contains a telemetry opt-out config entry</item>
    /// <item>The IDE instance has opted out of telemetry</item>
    /// </list>
    /// </summary>
    public bool IsTelemetryEnabled => _telemetrySender.State == TelemetrySessionState.Enabled;
 
    /// <summary>
    /// Call before using any telemetry methods. This will initialize the telemetry service and ensure that <see cref="DashboardTelemetryService.IsTelemetryEnabled"/> is set
    /// by making a request to the debug session, if one exists.
    /// </summary>
    public async Task InitializeAsync()
    {
        if (IsTelemetryInitialized)
        {
            return;
        }
 
        // Async lock to ensure that telemetry is only initialized once.
        await _lock.WaitAsync().ConfigureAwait(false);
 
        try
        {
            if (IsTelemetryInitialized)
            {
                return;
            }
 
            _logger.LogDebug("Initializing telemetry service.");
            await _telemetrySender.TryStartTelemetrySessionAsync().ConfigureAwait(false);
            _logger.LogDebug("Initialized telemetry service. Telemetry sender state: {TelemetrySenderState}", _telemetrySender.State);
 
            // Post session property values after initialization, if telemetry has been enabled.
            if (IsTelemetryEnabled)
            {
                foreach (var (key, value) in GetDefaultProperties())
                {
                    PostProperty(key, value);
                }
            }
        }
        finally
        {
            _lock.Release();
        }
    }
 
    private bool SkipQueuingRequests()
    {
        // Don't queue requests if we know the sender isn't enabled. This is a performance optimization.
        // Queue requests if enabled or not yet initialized.
        return !IsTelemetryEnabled;
    }
 
    /// <summary>
    /// Begin a long-running user operation. Prefer this over <see cref="PostOperation"/>. If an explicit user task caused this operation to start,
    /// use <see cref="StartUserTask"/> instead. Duration will be automatically calculated and the end event posted after <see cref="DashboardTelemetryService.EndOperation"/> is called.
    /// </summary>
    public OperationContext StartOperation(string eventName, Dictionary<string, AspireTelemetryProperty> startEventProperties, TelemetrySeverity severity = TelemetrySeverity.Normal, bool isOptOutFriendly = false, bool postStartEvent = true, IEnumerable<OperationContextProperty>? correlations = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 2, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryStartOperation));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var scopeSettings = new AspireTelemetryScopeSettings(
                startEventProperties,
                severity,
                isOptOutFriendly,
                correlations?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray(),
                postStartEvent);
 
            var response = await PostRequestAsync<StartOperationRequest, StartOperationResponse>(client, TelemetryEndpoints.TelemetryStartOperation, new StartOperationRequest(eventName, scopeSettings)).ConfigureAwait(false);
            context.Properties[0].SetValue(response.OperationId);
            context.Properties[1].SetValue(response.Correlation);
        });
 
        return context;
    }
 
    /// <summary>
    /// Ends a long-running operation. This will post the end event and calculate the duration.
    /// </summary>
    public void EndOperation(OperationContextProperty? operationId, TelemetryResult result, string? errorMessage = null)
    {
        if (SkipQueuingRequests() || operationId is null)
        {
            return;
        }
 
        var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryEndOperation);
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryEndOperation, new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage)).ConfigureAwait(false);
        });
    }
 
    /// <summary>
    /// Begin a long-running user task. This will post the start event and calculate the duration.
    /// Duration will be automatically calculated and the end event posted after <see cref="EndUserTask"/> is called.
    /// </summary>
    public OperationContext StartUserTask(string eventName, Dictionary<string, AspireTelemetryProperty> startEventProperties, TelemetrySeverity severity = TelemetrySeverity.Normal, bool isOptOutFriendly = false, bool postStartEvent = true, IEnumerable<OperationContextProperty>? correlations = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 2, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryStartUserTask));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var scopeSettings = new AspireTelemetryScopeSettings(
                startEventProperties,
                severity,
                isOptOutFriendly,
                correlations?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray(),
                postStartEvent);
 
            var response = await PostRequestAsync<StartOperationRequest, StartOperationResponse>(client, TelemetryEndpoints.TelemetryStartUserTask, new StartOperationRequest(eventName, scopeSettings)).ConfigureAwait(false);
            context.Properties[0].SetValue(response.OperationId);
            context.Properties[1].SetValue(response.Correlation);
        });
 
        return context;
    }
 
    /// <summary>
    /// Ends a long-running user task. This will post the end event and calculate the duration.
    /// </summary>
    public void EndUserTask(OperationContextProperty? operationId, TelemetryResult result, string? errorMessage = null)
    {
        if (SkipQueuingRequests() || operationId is null)
        {
            return;
        }
 
        var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryEndUserTask);
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryEndUserTask, new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage)).ConfigureAwait(false);
        });
    }
 
    /// <summary>
    /// Posts a short-lived operation. If duration needs to be calculated, use <see cref="DashboardTelemetryService.StartOperation"/> and <see cref="DashboardTelemetryService.EndOperation"/> instead.
    /// If an explicit user task caused this operation to start, use <see cref="DashboardTelemetryService.PostUserTask"/> instead.
    /// <returns>Guid corresponding to the (as-of-yet-uncompleted) correlation returned from this request.</returns>
    /// </summary>
    public OperationContext PostOperation(string eventName, TelemetryResult result, string? resultSummary = null, Dictionary<string, AspireTelemetryProperty>? properties = null, IEnumerable<OperationContextProperty>? correlatedWith = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 1, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryPostOperation));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var request = new PostOperationRequest(
                eventName,
                result,
                resultSummary,
                properties,
                correlatedWith?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray());
 
            var response = await PostRequestAsync<PostOperationRequest, TelemetryEventCorrelation>(client, TelemetryEndpoints.TelemetryPostOperation, request).ConfigureAwait(false);
            context.Properties[0].SetValue(response);
        });
 
        return context;
    }
 
    /// <summary>
    /// Posts a short-lived user task. If duration needs to be calculated, use <see cref="DashboardTelemetryService.StartUserTask"/> and <see cref="DashboardTelemetryService.EndUserTask"/> instead.
    /// <returns>Guid corresponding to the (as-of-yet-uncompleted) correlation returned from this request.</returns>
    /// </summary>
    public OperationContext PostUserTask(string eventName, TelemetryResult result, string? resultSummary = null, Dictionary<string, AspireTelemetryProperty>? properties = null, IEnumerable<OperationContextProperty>? correlatedWith = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 1, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryPostUserTask));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var request = new PostOperationRequest(
                eventName,
                result,
                resultSummary,
                properties,
                correlatedWith?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray());
 
            var response = await PostRequestAsync<PostOperationRequest, TelemetryEventCorrelation>(client, TelemetryEndpoints.TelemetryPostUserTask, request).ConfigureAwait(false);
            context.Properties[0].SetValue(response);
        });
 
        return context;
    }
 
    /// <summary>
    /// Posts a fault event.
    /// <returns>Guid corresponding to the (as-of-yet-uncompleted) correlation returned from this request.</returns>
    /// </summary>
    public OperationContext PostFault(string eventName, string description, FaultSeverity severity, Dictionary<string, AspireTelemetryProperty>? properties = null, IEnumerable<OperationContextProperty>? correlatedWith = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 1, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryPostFault));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var request = new PostFaultRequest(
                eventName,
                description,
                severity,
                properties,
                correlatedWith?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray());
 
            var response = await PostRequestAsync<PostFaultRequest, TelemetryEventCorrelation>(client, TelemetryEndpoints.TelemetryPostFault, request).ConfigureAwait(false);
            context.Properties[0].SetValue(response);
        });
 
        return context;
    }
 
    /// <summary>
    /// Posts an asset event. This is used to track events that are related to a specific asset, whose correlations can be sent along with other events.
    /// Currently not used.
    /// <returns>Guid corresponding to the (as-of-yet-uncompleted) correlation returned from this request.</returns>
    /// </summary>
    public OperationContext PostAsset(string eventName, string assetId, int assetEventVersion, Dictionary<string, AspireTelemetryProperty>? additionalProperties = null, IEnumerable<OperationContextProperty>? correlatedWith = null)
    {
        if (SkipQueuingRequests())
        {
            return OperationContext.Empty;
        }
 
        var context = OperationContext.Create(propertyCount: 1, name: GetCompositeEventName(eventName, TelemetryEndpoints.TelemetryPostAsset));
        _telemetrySender.QueueRequest(context, async (client, propertyGetter) =>
        {
            var request = new PostAssetRequest(
                eventName,
                assetId,
                assetEventVersion,
                additionalProperties,
                correlatedWith?.Select(propertyGetter).Cast<TelemetryEventCorrelation>().ToArray());
 
            var response = await PostRequestAsync<PostAssetRequest, TelemetryEventCorrelation>(client, TelemetryEndpoints.TelemetryPostAsset, request).ConfigureAwait(false);
            context.Properties[0].SetValue(response);
        });
 
        return context;
    }
 
    /// <summary>
    /// Post a session property.
    /// </summary>
    public void PostProperty(string propertyName, AspireTelemetryProperty propertyValue)
    {
        if (SkipQueuingRequests())
        {
            return;
        }
 
        var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryPostProperty);
        _telemetrySender.QueueRequest(context, async (client, _) =>
        {
            var request = new PostPropertyRequest(propertyName, propertyValue);
            await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostProperty, request).ConfigureAwait(false);
        });
    }
 
    /// <summary>
    /// Post a session recurring property.
    /// </summary>
    public void PostRecurringProperty(string propertyName, AspireTelemetryProperty propertyValue)
    {
        if (SkipQueuingRequests())
        {
            return;
        }
 
        var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryPostRecurringProperty);
        _telemetrySender.QueueRequest(context, async (client, _) =>
        {
            var request = new PostPropertyRequest(propertyName, propertyValue);
            await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostRecurringProperty, request).ConfigureAwait(false);
        });
    }
 
    /// <summary>
    /// Currently not used.
    /// </summary>
    public void PostCommandLineFlags(List<string> flagPrefixes, Dictionary<string, AspireTelemetryProperty> additionalProperties)
    {
        if (SkipQueuingRequests())
        {
            return;
        }
 
        var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryPostCommandLineFlags);
        _telemetrySender.QueueRequest(context, async (client, _) =>
        {
            var request = new PostCommandLineFlagsRequest(flagPrefixes, additionalProperties);
            await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostCommandLineFlags, request).ConfigureAwait(false);
        });
    }
 
    /// <summary>
    /// Gets identifying properties for the telemetry session.
    /// </summary>
    public Dictionary<string, AspireTelemetryProperty> GetDefaultProperties()
    {
        return new Dictionary<string, AspireTelemetryProperty>
        {
            { TelemetryPropertyKeys.DashboardVersion, new AspireTelemetryProperty(typeof(DashboardWebApplication).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? string.Empty) },
            { TelemetryPropertyKeys.DashboardBuildId, new AspireTelemetryProperty(typeof(DashboardWebApplication).Assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? string.Empty) },
        };
    }
 
    private static async Task<TResponse> PostRequestAsync<TRequest, TResponse>(HttpClient client, string endpoint, TRequest request)
    {
        var httpResponseMessage = await client.PostAsJsonAsync(endpoint, request).ConfigureAwait(false);
        httpResponseMessage.EnsureSuccessStatusCode();
        var response = await httpResponseMessage.Content.ReadFromJsonAsync<TResponse>().ConfigureAwait(false);
        if (response is null)
        {
            throw new InvalidOperationException("Response was null.");
        }
        return response;
    }
 
    private static string GetCompositeEventName(string eventName, string endpoint)
    {
        return $"{endpoint} - ${eventName}";
    }
}
 
public static class TelemetryEndpoints
{
    public const string TelemetryEnabled = "/telemetry/enabled";
    public const string TelemetryStart = "/telemetry/start";
    public const string TelemetryStartOperation = "/telemetry/startOperation";
    public const string TelemetryEndOperation = "/telemetry/endOperation";
    public const string TelemetryStartUserTask = "/telemetry/startUserTask";
    public const string TelemetryEndUserTask = "/telemetry/endUserTask";
    public const string TelemetryPostOperation = "/telemetry/operation";
    public const string TelemetryPostUserTask = "/telemetry/userTask";
    public const string TelemetryPostFault = "/telemetry/fault";
    public const string TelemetryPostAsset = "/telemetry/asset";
    public const string TelemetryPostProperty = "/telemetry/property";
    public const string TelemetryPostRecurringProperty = "/telemetry/recurringProperty";
    public const string TelemetryPostCommandLineFlags = "/telemetry/commandLineFlags";
}