File: Telemetry\AspireCliTelemetry.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.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Telemetry;
 
/// <summary>
/// Provides a single ActivitySource for all Aspire CLI components.
/// </summary>
internal sealed class AspireCliTelemetry : IHostedService
{
    /// <summary>
    /// The name of the ActivitySource for report telemetry. This telemetry is exported to external systems.
    /// </summary>
    public const string ReportedActivitySourceName = "Aspire.Cli.Reported";
 
    /// <summary>
    /// The name of the ActivitySource for diagnostics telemetry. This telemetry is used for internal diagnostics only.
    /// </summary>
    public const string DiagnosticsActivitySourceName = "Aspire.Cli.Diagnostics";
 
    /// <summary>
    /// Environment variable to opt out of telemetry. Set to "1" or "true" to disable.
    /// </summary>
    internal const string TelemetryOptOutConfigKey = "ASPIRE_CLI_TELEMETRY_OPTOUT";
 
    /// <summary>
    /// Environment variable for OpenTelemetry Protocol exporter endpoint.
    /// </summary>
    internal const string OtlpExporterEndpointConfigKey = "OTEL_EXPORTER_OTLP_ENDPOINT";
 
    /// <summary>
    /// Environment variable to specify the console exporter level for debugging.
    /// Set to "Reported" to export reported telemetry, or "Diagnostic" to export diagnostic telemetry.
    /// </summary>
    internal const string ConsoleExporterLevelConfigKey = "ASPIRE_CLI_CONSOLE_EXPORTER_LEVEL";
 
    private readonly ActivitySource _diagnosticsActivitySource;
    private readonly ActivitySource _reportedActivitySource;
    private readonly IMachineInformationProvider _machineInformationProvider;
    private readonly ICIEnvironmentDetector _ciEnvironmentDetector;
    private readonly ILogger<AspireCliTelemetry> _logger;
    private readonly List<KeyValuePair<string, object?>> _tagsList = [];
 
    private bool _isInitialized;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AspireCliTelemetry"/> class.
    /// </summary>
    /// <param name="logger">The logger instance for recording errors.</param>
    /// <param name="machineInformationProvider">The machine information provider.</param>
    /// <param name="ciEnvironmentDetector">The CI environment detector.</param>
    public AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector)
        : this(logger, machineInformationProvider, ciEnvironmentDetector, ReportedActivitySourceName, DiagnosticsActivitySourceName)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AspireCliTelemetry"/> class with custom activity source names.
    /// This constructor is intended for testing purposes only to enable thread-safe test isolation.
    /// </summary>
    /// <param name="logger">The logger instance for recording errors.</param>
    /// <param name="machineInformationProvider">The machine information provider.</param>
    /// <param name="ciEnvironmentDetector">The CI environment detector.</param>
    /// <param name="reportedSourceName">The name for the reported activity source.</param>
    /// <param name="diagnosticsSourceName">The name for the diagnostics activity source.</param>
    internal AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, string reportedSourceName, string diagnosticsSourceName)
    {
        _logger = logger;
        _machineInformationProvider = machineInformationProvider;
        _ciEnvironmentDetector = ciEnvironmentDetector;
        _reportedActivitySource = new ActivitySource(reportedSourceName);
        _diagnosticsActivitySource = new ActivitySource(diagnosticsSourceName);
    }
 
    /// <summary>
    /// TESTING PURPOSES ONLY: Gets the default tags used for telemetry.
    /// </summary>
    internal IReadOnlyList<KeyValuePair<string, object?>> GetDefaultTags()
    {
        CheckInitialization();
        return [.. _tagsList];
    }
 
    /// <summary>
    /// Starts a new activity for reported telemetry that is exported to external systems.
    /// </summary>
    /// <param name="name">The name of the activity.</param>
    /// <param name="kind">The activity kind.</param>
    /// <returns>The started activity, or null if no listeners are registered.</returns>
    public Activity? StartReportedActivity([CallerMemberName] string name = "", ActivityKind kind = ActivityKind.Internal)
    {
        return StartActivityCore(_reportedActivitySource, name, kind);
    }
 
    /// <summary>
    /// Starts a new activity for diagnostic telemetry used for internal diagnostics only.
    /// Uses the caller member name if no name is provided.
    /// </summary>
    /// <param name="name">The name of the activity. Defaults to the caller member name if not specified.</param>
    /// <param name="kind">The activity kind.</param>
    /// <returns>The started activity, or null if no listeners are registered.</returns>
    public Activity? StartDiagnosticActivity([CallerMemberName] string name = "", ActivityKind kind = ActivityKind.Internal)
    {
        return StartActivityCore(_diagnosticsActivitySource, name, kind);
    }
 
    private Activity? StartActivityCore(ActivitySource source, string name, ActivityKind kind)
    {
        CheckInitialization();
 
        // Activities must have a name.
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 
        var activity = source.StartActivity(name, kind);
 
        if (activity is not null)
        {
            foreach (var tag in _tagsList)
            {
                activity.AddTag(tag.Key, tag.Value);
            }
        }
 
        return activity;
    }
 
    /// <summary>
    /// Records an error by logging it and adding an activity event to a CLI activity.
    /// </summary>
    /// <param name="message">The error message.</param>
    /// <param name="exception">The exception that occurred.</param>
    public void RecordError(string message, Exception exception)
    {
        CheckInitialization();
 
        _logger.LogError(exception, message);
 
        var activity = FindReportedActivity(Activity.Current);
        if (activity is not null)
        {
            // This adds an activity event for the error. Capturing the data manually is intentional.
            // The reason is we want to record this information to the traces table instead of the exceptions table.
            var tags = new ActivityTagsCollection
            {
                [TelemetryConstants.Tags.ExceptionType] = exception.GetType().FullName,
                [TelemetryConstants.Tags.ExceptionMessage] = exception.Message,
                [TelemetryConstants.Tags.ExceptionStackTrace] = exception.StackTrace
            };
 
            foreach (var tag in _tagsList)
            {
                tags[tag.Key] = tag.Value;
            }
 
            activity.AddEvent(new ActivityEvent(TelemetryConstants.Events.Error, tags: tags));
        }
        else
        {
            // There should always be a reported activity. Sanity check in case something goes wrong.
            Debug.WriteLine("No reported activity found to record the error event.");
        }
    }
 
    /// <inheritdoc />
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        await InitializeAsync().ConfigureAwait(false);
    }
 
    /// <inheritdoc />
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    /// <summary>
    /// Initializes the telemetry service by collecting machine information.
    /// </summary>
    internal async Task InitializeAsync()
    {
        if (_isInitialized)
        {
            return;
        }
 
        try
        {
            var macAddressHashTask = _machineInformationProvider.GetMacAddressHash();
            var deviceIdTask = _machineInformationProvider.GetOrCreateDeviceId();
 
            await Task.WhenAll(new Task[] { macAddressHashTask, deviceIdTask }).ConfigureAwait(false);
 
            _tagsList.Add(new(TelemetryConstants.Tags.MacAddressHash, macAddressHashTask.Result));
            _tagsList.Add(new(TelemetryConstants.Tags.DeviceId, deviceIdTask.Result));
 
            // This is consistent with dashboard version data.
            _tagsList.Add(new(TelemetryConstants.Tags.CliVersion, typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? string.Empty));
            _tagsList.Add(new(TelemetryConstants.Tags.CliBuildId, typeof(Program).Assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? string.Empty));
 
            _tagsList.Add(new(TelemetryConstants.Tags.DeploymentEnvironmentName, _ciEnvironmentDetector.IsCIEnvironment() ? "ci" : "local"));
        }
        catch (Exception ex)
        {
            // Don't throw an error if there is a telemetry issue.
            _logger.LogError(ex, "Error occurred initializing telemetry service.");
        }
        finally
        {
            _isInitialized = true;
        }
    }
 
    private void CheckInitialization()
    {
        if (!_isInitialized)
        {
            throw new InvalidOperationException(
                $"Telemetry service has not been initialized. Use {nameof(InitializeAsync)}() before any other operations.");
        }
    }
 
    /// <summary>
    /// Searches the activity hierarchy to find the first reported activity.
    /// We want to log errors only to the reported activity so they're reported.
    /// </summary>
    private Activity? FindReportedActivity(Activity? activity)
    {
        while (activity is not null)
        {
            if (activity.Source == _reportedActivitySource)
            {
                return activity;
            }
 
            activity = activity.Parent;
        }
 
        return null;
    }
}