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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Aspire.Hosting;
using Aspire.Shared;
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 = KnownOtelConfigNames.ExporterOtlpEndpoint;
 
    /// <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 ICodingAgentDetector _codingAgentDetector;
    private readonly IInternalMicrosoftDetector _internalMicrosoftDetector;
    private readonly TelemetryConfiguration _telemetryConfiguration;
    private readonly ILogger<AspireCliTelemetry> _logger;
    private readonly CliExecutionContext _executionContext;
    private readonly TelemetryTagsSource _tagsSource;
 
    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>
    /// <param name="codingAgentDetector">The coding agent detector.</param>
    /// <param name="internalMicrosoftDetector">The internal Microsoft detector.</param>
    /// <param name="telemetryConfiguration">The telemetry configuration.</param>
    /// <param name="executionContext">
    /// The CLI execution context carrying the effective identity. Required: the DI
    /// container injects the registered singleton, so identity telemetry tags are
    /// always emitted from it.
    /// </param>
    /// <param name="tagsSource">The shared source for background-calculated telemetry tags.</param>
    public AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector, IInternalMicrosoftDetector internalMicrosoftDetector, TelemetryConfiguration telemetryConfiguration, CliExecutionContext executionContext, TelemetryTagsSource tagsSource)
        : this(logger, machineInformationProvider, ciEnvironmentDetector, codingAgentDetector, internalMicrosoftDetector, telemetryConfiguration, ReportedActivitySourceName, DiagnosticsActivitySourceName, executionContext, tagsSource)
    {
    }
 
    /// <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="codingAgentDetector">The coding agent detector.</param>
    /// <param name="internalMicrosoftDetector">The internal Microsoft detector.</param>
    /// <param name="reportedSourceName">The name for the reported activity source.</param>
    /// <param name="diagnosticsSourceName">The name for the diagnostics activity source.</param>
    /// <param name="executionContext">The CLI execution context carrying the effective identity.</param>
    /// <param name="tagsSource">The shared source for background-calculated telemetry tags.</param>
    internal AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector, IInternalMicrosoftDetector internalMicrosoftDetector, string reportedSourceName, string diagnosticsSourceName, CliExecutionContext executionContext, TelemetryTagsSource tagsSource)
        : this(logger, machineInformationProvider, ciEnvironmentDetector, codingAgentDetector, internalMicrosoftDetector, new TelemetryConfiguration { ReportedTelemetryEnabled = true }, reportedSourceName, diagnosticsSourceName, executionContext, tagsSource)
    {
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AspireCliTelemetry"/> class with custom telemetry enablement.
    /// </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="codingAgentDetector">The coding agent detector.</param>
    /// <param name="internalMicrosoftDetector">The internal Microsoft detector.</param>
    /// <param name="telemetryConfiguration">The telemetry configuration.</param>
    /// <param name="reportedSourceName">The name for the reported activity source.</param>
    /// <param name="diagnosticsSourceName">The name for the diagnostics activity source.</param>
    /// <param name="executionContext">The CLI execution context carrying the effective identity.</param>
    /// <param name="tagsSource">The shared source for background-calculated telemetry tags.</param>
    internal AspireCliTelemetry(ILogger<AspireCliTelemetry> logger, IMachineInformationProvider machineInformationProvider, ICIEnvironmentDetector ciEnvironmentDetector, ICodingAgentDetector codingAgentDetector, IInternalMicrosoftDetector internalMicrosoftDetector, TelemetryConfiguration telemetryConfiguration, string reportedSourceName, string diagnosticsSourceName, CliExecutionContext executionContext, TelemetryTagsSource tagsSource)
    {
        _logger = logger;
        _machineInformationProvider = machineInformationProvider;
        _ciEnvironmentDetector = ciEnvironmentDetector;
        _codingAgentDetector = codingAgentDetector;
        _internalMicrosoftDetector = internalMicrosoftDetector;
        _telemetryConfiguration = telemetryConfiguration;
        _executionContext = executionContext;
        _tagsSource = tagsSource;
        _reportedActivitySource = new ActivitySource(reportedSourceName);
        _diagnosticsActivitySource = new ActivitySource(diagnosticsSourceName);
    }
 
    /// <summary>
    /// TESTING PURPOSES ONLY: Gets the default tags used for telemetry.
    /// </summary>
    internal async Task<IReadOnlyList<KeyValuePair<string, object?>>> GetDefaultTagsAsync()
    {
        return await _tagsSource.TagsTask.ConfigureAwait(false);
    }
 
    /// <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 reported telemetry with an explicit parent context.
    /// </summary>
    public Activity? StartReportedActivity(string name, ActivityKind kind, ActivityContext parentContext)
    {
        return StartActivityCore(_reportedActivitySource, name, kind, parentContext);
    }
 
    /// <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);
    }
 
    /// <summary>
    /// Starts a new activity for diagnostic telemetry with an explicit parent context.
    /// </summary>
    public Activity? StartDiagnosticActivity(string name, ActivityKind kind, ActivityContext parentContext)
    {
        return StartActivityCore(_diagnosticsActivitySource, name, kind, parentContext);
    }
 
    private static Activity? StartActivityCore(ActivitySource source, string name, ActivityKind kind)
    {
        return StartActivityCore(source, name, kind, parentContext: null);
    }
 
    private static Activity? StartActivityCore(ActivitySource source, string name, ActivityKind kind, ActivityContext? parentContext)
    {
        // Activities must have a name.
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
 
        var activity = parentContext is { } context
            ? source.StartActivity(name, kind, context)
            : source.StartActivity(name, kind);
 
        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)
    {
        _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 _tagsSource.GetResolvedTags())
            {
                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 Task StartAsync(CancellationToken cancellationToken)
    {
        Initialize();
        return Task.CompletedTask;
    }
 
    /// <inheritdoc />
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    /// <summary>
    /// Starts background tag calculation. Returns immediately; the tags become available
    /// asynchronously through <see cref="TelemetryTagsSource.TagsTask"/>.
    /// </summary>
    internal void Initialize()
    {
        if (_isInitialized)
        {
            return;
        }
 
        _isInitialized = true;
 
        _tagsSource.StartCalculation(async () =>
        {
            try
            {
                var tagsList = new List<KeyValuePair<string, object?>>();
 
                var macAddressHashTask = _machineInformationProvider.GetMacAddressHash();
                var deviceIdTask = _machineInformationProvider.GetOrCreateDeviceId();
 
                Task<InternalMicrosoftDetectionResult>? internalMicrosoftTask = null;
                if (_telemetryConfiguration.ReportedTelemetryEnabled)
                {
                    // The internal Microsoft check can be slow and can perform multiple async operations in parallel, so only run it if reported
                    // telemetry is enabled. Use CancellationToken.None because background tag calculation should not be interrupted by app shutdown.
                    internalMicrosoftTask = _internalMicrosoftDetector.IsInternalMicrosoftMachineAsync(CancellationToken.None);
                }
 
                await Task.WhenAll(new Task[] { macAddressHashTask, deviceIdTask }).ConfigureAwait(false);
 
                InternalMicrosoftDetectionResult? internalMicrosoftResult = null;
                if (internalMicrosoftTask is not null)
                {
                    try
                    {
                        internalMicrosoftResult = await internalMicrosoftTask.ConfigureAwait(false);
                    }
                    catch (Exception ex)
                    {
                        if (_logger.IsEnabled(LogLevel.Debug))
                        {
                            _logger.LogDebug(ex, "Internal Microsoft detection failed.");
                        }
                    }
                }
 
                tagsList.Add(new(TelemetryConstants.Tags.MacAddressHash, macAddressHashTask.Result));
                tagsList.Add(new(TelemetryConstants.Tags.DeviceId, deviceIdTask.Result));
                if (internalMicrosoftResult is { IsInternalMicrosoft: true })
                {
                    tagsList.Add(new(TelemetryConstants.Tags.InternalMicrosoft, internalMicrosoftResult.IsInternalMicrosoft));
                    if (!string.IsNullOrEmpty(internalMicrosoftResult.Source))
                    {
                        tagsList.Add(new(TelemetryConstants.Tags.InternalMicrosoftSource, internalMicrosoftResult.Source));
                    }
 
                    if (!string.IsNullOrEmpty(internalMicrosoftResult.Alias))
                    {
                        tagsList.Add(new(TelemetryConstants.Tags.InternalMicrosoftAlias, internalMicrosoftResult.Alias));
                    }
 
                    if (!string.IsNullOrEmpty(internalMicrosoftResult.Domain))
                    {
                        tagsList.Add(new(TelemetryConstants.Tags.InternalMicrosoftDomain, internalMicrosoftResult.Domain));
                    }
                }
 
                // This is consistent with dashboard version data.
                tagsList.Add(new(TelemetryConstants.Tags.CliVersion, GetCliVersion()));
                tagsList.Add(new(TelemetryConstants.Tags.CliBuildId, GetCliBuildId()));
 
                // Identity tags describe the build the CLI is *behaving* as (env / sidecar overrides),
                // kept separate from the physical binary's cli.version/cli.build_id above so emulated
                // runs are distinguishable in telemetry. See docs/specs/cli-identity-sidecar.md.
                tagsList.Add(new(TelemetryConstants.Tags.IdentityVersion, _executionContext.IdentityVersion));
                tagsList.Add(new(TelemetryConstants.Tags.IdentityChannel, _executionContext.IdentityChannel));
                if (!string.IsNullOrEmpty(_executionContext.IdentityCommit))
                {
                    tagsList.Add(new(TelemetryConstants.Tags.IdentityCommit, _executionContext.IdentityCommit));
                }
 
                var codingAgent = _codingAgentDetector.GetCodingAgent();
                if (codingAgent is not null)
                {
                    tagsList.Add(new(TelemetryConstants.Tags.CodingAgent, codingAgent));
                }
 
                tagsList.Add(new(TelemetryConstants.Tags.DeploymentEnvironmentName, _ciEnvironmentDetector.IsCIEnvironment() ? "ci" : "local"));
 
                tagsList.Add(new(TelemetryConstants.Tags.OsName, GetOsName()));
                tagsList.Add(new(TelemetryConstants.Tags.OsType, GetOsType()));
                tagsList.Add(new(TelemetryConstants.Tags.OsVersion, Environment.OSVersion.Version.ToString()));
 
                return (IReadOnlyList<KeyValuePair<string, object?>>)tagsList;
            }
            catch (Exception ex)
            {
                // Don't throw an error if there is a telemetry issue.
                _logger.LogError(ex, "Error occurred initializing telemetry service.");
                return Array.Empty<KeyValuePair<string, object?>>();
            }
        });
    }
 
    /// <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;
    }
 
    /// <summary>
    /// Gets the human-readable operating system name for the <c>os.name</c> semantic convention.
    /// </summary>
    internal static string GetOsName()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            return "Windows";
        }
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            return "Linux";
        }
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            return "macOS";
        }
 
        return RuntimeInformation.OSDescription;
    }
 
    /// <summary>
    /// Gets the OpenTelemetry semantic convention value for the <c>os.type</c> attribute.
    /// </summary>
    internal static string GetOsType()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            return "windows";
        }
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            return "linux";
        }
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            return "darwin";
        }
 
        return "unknown";
    }
 
    /// <summary>
    /// Gets the CLI version from the assembly's informational version attribute.
    /// </summary>
    /// <remarks>
    /// physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md): the
    /// <c>cli.version</c> telemetry tag identifies the actual running binary, so it reads the
    /// assembly directly and is NOT replaced by an emulated <c>ASPIRE_CLI_VERSION</c> identity.
    /// The emulated identity is emitted separately via the <c>identity.*</c> tags.
    /// </remarks>
    /// <returns>The CLI version string, or an empty string if not available.</returns>
    internal static string GetCliVersion()
    {
        return AssemblyVersionHelper.GetInformationalVersion(typeof(Program).Assembly);
    }
 
    /// <summary>
    /// Gets the CLI build ID from the assembly's file version attribute.
    /// </summary>
    /// <returns>The CLI build ID string, or an empty string if not available.</returns>
    internal static string GetCliBuildId()
    {
        return AssemblyVersionHelper.GetFileVersion(typeof(Program).Assembly);
    }
}