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