| File: Telemetry\TelemetryManager.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 Aspire.Cli.Utils; using Azure.Monitor.OpenTelemetry.Exporter; using Microsoft.Extensions.Configuration; using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; namespace Aspire.Cli.Telemetry; // This file is the CLI's OpenTelemetry wiring point. It decides which ActivitySources // are listened to and where they are exported; the activity creation APIs live in // AspireCliTelemetry and ProfilingTelemetry. // // Keep reported telemetry, profiling telemetry, and debug diagnostics on separate providers. // Reported telemetry is allowed to leave the machine through Azure Monitor, while // profiling and diagnostic telemetry are intentionally local and opt-in because they can // include high-cardinality process, path, and startup timing details. // // Enablement is intentionally separate: // - Reported telemetry is on by default and is disabled with ASPIRE_CLI_TELEMETRY_OPTOUT=true. // - Profiling telemetry requires ASPIRE_PROFILING_ENABLED=true plus OTEL_EXPORTER_OTLP_ENDPOINT // (and typically OTEL_EXPORTER_OTLP_PROTOCOL=grpc). ASPIRE_STARTUP_PROFILING_ENABLED is the // legacy alias that remains supported for existing scripts. // - DEBUG-only diagnostics use ASPIRE_CLI_CONSOLE_EXPORTER_LEVEL=Diagnostic, or OTLP export when // OTEL_EXPORTER_OTLP_ENDPOINT is set without profiling enabled. /// <summary> /// Manages OpenTelemetry TracerProvider instances for the CLI. /// Maintains separate providers for reported telemetry, profiling telemetry, and debug diagnostics. /// </summary> internal sealed class TelemetryManager : IDisposable { // Remote export connection string for Application Insights. Intentionally hard-coded. private const string ApplicationInsightsConnectionString = "InstrumentationKey=e39510fc-95a1-423d-9f33-6121bf0d2113;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/;LiveEndpoint=https://centralus.livediagnostics.monitor.azure.com/;ApplicationId=4d8bb9db-b7ab-49f9-978b-80ae1e83f6da"; #if DEBUG // No timeout in debug builds private const int ShutDownTimeoutMilliseconds = -1; #else // Chosen to provide time to send remaining telemetry without noticeably delaying exit. private const int ShutDownTimeoutMilliseconds = 200; #endif private const int ProfilingForceFlushTimeoutMilliseconds = 5000; // The agent telemetry command runs fire-and-forget from an agent hook and exits immediately, // so the short Release shutdown flush (200ms) is not enough to reliably export the single // just-created span. The command path force-flushes the reported provider with this larger // bound before exit so the event is not silently dropped. private const int ReportedForceFlushTimeoutMilliseconds = 3000; private readonly TracerProvider? _azureMonitorProvider; private readonly TracerProvider? _profilingProvider; private readonly TracerProvider? _debugDiagnosticProvider; private bool _shuttingDown; /// <summary> /// Initializes a new instance of the <see cref="TelemetryManager"/> class. /// </summary> /// <param name="telemetryConfiguration">The telemetry configuration.</param> /// <param name="tagsSource">The shared source for background-calculated telemetry tags.</param> public TelemetryManager(TelemetryConfiguration telemetryConfiguration, TelemetryTagsSource tagsSource) { #if DEBUG // Preserve the DEBUG-only diagnostic OTLP path for non-profiling diagnostics. When // profiling is enabled, the same OTLP endpoint is reserved for the profiling provider // so reported/diagnostic sources do not get mixed into startup profiling exports. var useDebugDiagnosticOtlpExporter = telemetryConfiguration.RequestedOtlpExporter && !telemetryConfiguration.ProfilingEnabled; #else var useDebugDiagnosticOtlpExporter = false; #endif var useDebugDiagnosticProvider = useDebugDiagnosticOtlpExporter || telemetryConfiguration.ConsoleExporterLevel == ConsoleExporterLevel.Diagnostic; // Don't create any providers if nothing is enabled if (!telemetryConfiguration.ReportedTelemetryEnabled && !telemetryConfiguration.UseProfilingProvider && !useDebugDiagnosticProvider) { return; } var resource = ResourceBuilder.CreateDefault().AddService( serviceName: "aspire-cli", // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md): // the OTel service version identifies the actual running binary that produced the // telemetry, so it must NOT be replaced by an emulated ASPIRE_CLI_VERSION identity. // The emulated identity is emitted separately as identity.* tags (AspireCliTelemetry). serviceVersion: VersionHelper.GetDefaultTemplateVersion()); // Create Azure Monitor provider if connection string is provided. // The Azure Monitor only exports telemetry from the Reported activity source. if (telemetryConfiguration.ReportedTelemetryEnabled) { var azureMonitorBuilder = CreateTracerProviderBuilder(AspireCliTelemetry.ReportedActivitySourceName, resource, tagsSource) .AddAzureMonitorTraceExporter(o => { o.ConnectionString = ApplicationInsightsConnectionString; o.EnableLiveMetrics = false; o.StorageDirectory = GetTelemetryStoragePath(); // Capture 100% of reported telemetry. The exporter defaults to a RateLimitedSampler // (TracesPerSecond = 5), which keeps a span with probability // min(elapsed_since_provider_built * tracesPerSecond, 1). That model assumes a // long-lived, high-volume process; the CLI is the opposite (fire-and-forget, often a // single span per process), so every span is judged at cold-start probability and // ~half are silently dropped as a startup-timing artifact rather than a deliberate // policy. Reported volume is a handful of spans per run, so full capture is cheap and // is what adoption analytics needs. TracesPerSecond takes precedence over SamplingRatio // in the exporter, so it must be nulled for the 100% ratio to take effect. o.TracesPerSecond = null; o.SamplingRatio = 1.0f; }); #if DEBUG if (telemetryConfiguration.ConsoleExporterLevel == ConsoleExporterLevel.Reported) { azureMonitorBuilder.AddConsoleExporter(); } #endif _azureMonitorProvider = azureMonitorBuilder.Build(); } if (telemetryConfiguration.UseProfilingProvider) { _profilingProvider = CreateTracerProviderBuilder(ProfilingTelemetry.ActivitySourceName, resource, tagsSource) .AddOtlpExporter() .Build(); } if (useDebugDiagnosticProvider) { var diagnosticBuilder = CreateTracerProviderBuilder(AspireCliTelemetry.DiagnosticsActivitySourceName, resource, tagsSource); if (telemetryConfiguration.ConsoleExporterLevel == ConsoleExporterLevel.Diagnostic) { diagnosticBuilder.AddConsoleExporter(); } if (useDebugDiagnosticOtlpExporter) { diagnosticBuilder.AddOtlpExporter(); } _debugDiagnosticProvider = diagnosticBuilder.Build(); } } private static TracerProviderBuilder CreateTracerProviderBuilder(string sourceName, ResourceBuilder resource, TelemetryTagsSource tagsSource) { return Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) .SetResourceBuilder(resource) .AddProcessor(new CliTagEnrichmentProcessor(tagsSource)); } /// <summary> /// Initializes a new instance of the <see cref="TelemetryManager"/> class. /// </summary> /// <param name="configuration">The configuration to read telemetry settings from.</param> /// <param name="tagsSource">The shared source for background-calculated telemetry tags.</param> /// <param name="args">The command-line arguments.</param> internal TelemetryManager(IConfiguration configuration, TelemetryTagsSource tagsSource, string[]? args = null) : this(TelemetryConfiguration.Create(configuration, args), tagsSource) { } /// <summary> /// Gets whether Azure Monitor telemetry is enabled. /// </summary> public bool HasAzureMonitor => _azureMonitorProvider is not null; /// <summary> /// Gets whether profiling telemetry export is enabled. /// </summary> public bool HasProfilingProvider => _profilingProvider is not null; /// <summary> /// Gets whether DEBUG-only diagnostic telemetry export is enabled. /// </summary> public bool HasDiagnosticProvider => _debugDiagnosticProvider is not null; /// <summary> /// Flushes profiling telemetry without shutting down other telemetry providers. /// </summary> public Task ForceFlushProfilingAsync() { // OpenTelemetry's TracerProvider flush API is the synchronous // ForceFlush(int timeoutMilliseconds) extension method. It can block until the batch // exporter drains or the timeout expires, so keep the CLI profile export path async by // running that bounded wait on the thread pool; callers still await this so export does not // race ahead of pending spans. Adding cancellation here would either skip the flush before // it starts or stop waiting while the synchronous flush keeps running; the provider timeout // is the actual bound for this best-effort drain. return Task.Run(() => { _profilingProvider?.ForceFlush(ProfilingForceFlushTimeoutMilliseconds); }); } /// <summary> /// Flushes reported telemetry without shutting down other telemetry providers. /// </summary> /// <remarks> /// Used by the <c>aspire agent telemetry</c> command, which is invoked fire-and-forget from an /// agent hook and exits immediately. The normal shutdown flush window is too short to reliably /// drain a single just-created span, so this bounded flush ensures the event leaves the process. /// </remarks> public Task ForceFlushReportedAsync() { // See ForceFlushProfilingAsync for why this runs the synchronous, bounded // ForceFlush(int) on the thread pool rather than taking a CancellationToken. return Task.Run(() => { _azureMonitorProvider?.ForceFlush(ReportedForceFlushTimeoutMilliseconds); }); } /// <summary> /// Shuts down the telemetry providers, flushing any pending telemetry. /// </summary> public Task ShutdownAsync() { _shuttingDown = true; return Task.Run(() => { _azureMonitorProvider?.Shutdown(ShutDownTimeoutMilliseconds); _profilingProvider?.Shutdown(ShutDownTimeoutMilliseconds); _debugDiagnosticProvider?.Shutdown(ShutDownTimeoutMilliseconds); }); } private static string GetTelemetryStoragePath() { var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); return Path.Combine(homeDirectory, ".aspire", "cli", "telemetrystorage"); } public void Dispose() { if (!_shuttingDown) { // Ensure everything is cleaned up for tests. This covers the situation where the host is disposed without a call to ShutdownAsync. // The shutdown timeout is zero so not to wait for telemetry to be flushed. Don't want to delay tests. // Dispose isn't used here because it always flushes telemetry and waits for completion. _azureMonitorProvider?.Shutdown(0); _profilingProvider?.Shutdown(0); _debugDiagnosticProvider?.Shutdown(0); } } }