// 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);
}
}