File: Commands\ExportCommand.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.CommandLine;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
using Aspire.Shared.Export;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Command to export telemetry and resource data to a zip file.
/// </summary>
internal sealed class ExportCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
 
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<ExportCommand> _logger;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly TimeProvider _timeProvider;
 
    private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
 
    private static readonly Option<string?> s_outputOption = new("--output", "-o")
    {
        Description = ExportCommandStrings.OutputOptionDescription
    };
 
    private static readonly Argument<string?> s_resourceArgument = new("resource")
    {
        Description = ExportCommandStrings.ResourceOptionDescription,
        Arity = ArgumentArity.ZeroOrOne
    };
 
    public ExportCommand(
        IInteractionService interactionService,
        IAuxiliaryBackchannelMonitor backchannelMonitor,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        AspireCliTelemetry telemetry,
        IHttpClientFactory httpClientFactory,
        TimeProvider timeProvider,
        ILogger<ExportCommand> logger)
        : base("export", ExportCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
    {
        _interactionService = interactionService;
        _httpClientFactory = httpClientFactory;
        _timeProvider = timeProvider;
        _logger = logger;
        _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
 
        Arguments.Add(s_resourceArgument);
        Options.Add(s_appHostOption);
        Options.Add(s_outputOption);
    }
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity(Name);
 
        var resourceName = parseResult.GetValue(s_resourceArgument);
        var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
        var outputPath = parseResult.GetValue(s_outputOption);
 
        // Resolve the AppHost connection for backchannel access
        var connectionResult = await _connectionResolver.ResolveConnectionAsync(
            passedAppHostProjectFile,
            SharedCommandStrings.ScanningForRunningAppHosts,
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ExportCommandStrings.SelectAppHostAction),
            SharedCommandStrings.AppHostNotRunning,
            cancellationToken);
 
        if (!connectionResult.Success)
        {
            _interactionService.DisplayMessage(KnownEmojis.Information, connectionResult.ErrorMessage);
            return ExitCodeConstants.Success;
        }
 
        var connection = connectionResult.Connection!;
 
        // Get dashboard API info for telemetry data
        var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken);
        var isDashboardAvailable = dashboardInfo?.ApiBaseUrl is not null && dashboardInfo.ApiToken is not null;
 
        if (!isDashboardAvailable)
        {
            _interactionService.DisplayMessage(KnownEmojis.Warning, ExportCommandStrings.DashboardNotAvailable);
        }
 
        var baseUrl = dashboardInfo?.ApiBaseUrl;
        var apiToken = dashboardInfo?.ApiToken;
 
        // Default file name if not specified
        if (string.IsNullOrEmpty(outputPath))
        {
            var timestamp = _timeProvider.GetLocalNow().ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
            outputPath = $"aspire-export-{timestamp}.zip";
        }
 
        // Ensure directory exists
        var directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }
 
        try
        {
            using var client = isDashboardAvailable ? TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken!) : null;
 
            // Get telemetry resources and resource snapshots
            var (telemetryResources, snapshots) = await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringResources, async () =>
            {
                var resources = isDashboardAvailable
                    ? await TelemetryCommandHelpers.GetAllResourcesAsync(client!, baseUrl!, cancellationToken).ConfigureAwait(false)
                    : [];
                var snaps = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false);
                return (resources, snaps);
            });
 
            // Validate resource name exists (match by Name or DisplayName since users may pass either)
            if (resourceName is not null)
            {
                if (!ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Any())
                {
                    _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ResourceNotFound, resourceName));
                    return ExitCodeConstants.InvalidCommand;
                }
            }
            else
            {
                if (snapshots.Count == 0)
                {
                    _interactionService.DisplayMessage(KnownEmojis.Information, ExportCommandStrings.NoResourcesFound);
                    return ExitCodeConstants.Success;
                }
            }
 
            // Resolve which telemetry resources match the filter
            List<string>? resolvedTelemetryResources = null;
            var hasTelemetryData = true;
            if (resourceName is not null)
            {
                hasTelemetryData = TelemetryCommandHelpers.TryResolveResourceNames(resourceName, telemetryResources, out resolvedTelemetryResources);
            }
 
            var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(telemetryResources);
 
            var exportArchive = new ExportArchive();
 
            // 1. Export resource details (filtered when a resource name is specified)
            AddResources(exportArchive, snapshots, resourceName);
 
            // 2. Export console logs from backchannel
            await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringConsoleLogs, async () =>
            {
                await AddConsoleLogsAsync(exportArchive, connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false);
                return true;
            });
 
            if (isDashboardAvailable && hasTelemetryData)
            {
                // 3. Export structured logs from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable)
                await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringStructuredLogs, async () =>
                {
                    await AddStructuredLogsAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false);
                    return true;
                });
 
                // 4. Export traces from Dashboard API (skip if resource has no telemetry data or dashboard is unavailable)
                await _interactionService.ShowStatusAsync(ExportCommandStrings.GatheringTraces, async () =>
                {
                    await AddTracesAsync(exportArchive, client!, baseUrl!, resolvedTelemetryResources, allOtlpResources, cancellationToken).ConfigureAwait(false);
                    return true;
                });
            }
 
            var fullPath = Path.GetFullPath(outputPath);
            exportArchive.WriteToFile(fullPath);
 
            _interactionService.DisplayMessage(KnownEmojis.CheckMark, string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.ExportComplete, fullPath));
            return ExitCodeConstants.Success;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to export telemetry data");
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ExportCommandStrings.FailedToExport, ex.Message));
            return ExitCodeConstants.DashboardFailure;
        }
    }
 
    private static void AddResources(ExportArchive exportArchive, IReadOnlyList<ResourceSnapshot> snapshots, string? resourceName)
    {
        var resourceJsonList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots);
        var matchingNames = resourceName is not null
            ? new HashSet<string>(ResourceSnapshotMapper.WhereMatchesResourceName(snapshots, resourceName).Select(s => s.Name), StringComparers.ResourceName)
            : null;
 
        foreach (var (snapshot, resourceJson) in snapshots.Zip(resourceJsonList))
        {
            if (matchingNames is not null && !matchingNames.Contains(snapshot.Name))
            {
                continue;
            }
 
            var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, snapshots);
            exportArchive.Resources[displayName] = resourceJson;
        }
    }
 
    private static async Task AddConsoleLogsAsync(
        ExportArchive exportArchive,
        IAppHostAuxiliaryBackchannel connection,
        string? resourceName,
        IReadOnlyList<ResourceSnapshot> snapshots,
        CancellationToken cancellationToken)
    {
        var logLinesByResource = new Dictionary<string, List<string>>();
 
        await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false))
        {
            if (!logLinesByResource.TryGetValue(logLine.ResourceName, out var lines))
            {
                lines = [];
                logLinesByResource[logLine.ResourceName] = lines;
            }
 
            lines.Add(logLine.Content);
        }
 
        foreach (var (name, lines) in logLinesByResource)
        {
            var snapshot = snapshots.FirstOrDefault(s => string.Equals(s.Name, name, StringComparisons.ResourceName));
            var displayName = snapshot is not null
                ? ResourceSnapshotMapper.GetResourceName(snapshot, snapshots)
                : name;
            exportArchive.ConsoleLogs[displayName] = lines;
        }
    }
 
    private static async Task AddStructuredLogsAsync(
        ExportArchive exportArchive,
        HttpClient client,
        string baseUrl,
        List<string>? resolvedResources,
        IReadOnlyList<IOtlpResource> allOtlpResources,
        CancellationToken cancellationToken)
    {
        var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources);
        var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
        response.EnsureSuccessStatusCode();
 
        var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        var apiResponse = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        if (apiResponse?.Data?.ResourceLogs is { Length: > 0 })
        {
            // Group by resolved resource name so each resource gets its own file
            var groups = apiResponse.Data.ResourceLogs
                .GroupBy(rl => TelemetryCommandHelpers.ResolveResourceName(rl.Resource, allOtlpResources));
 
            foreach (var group in groups)
            {
                exportArchive.StructuredLogs[group.Key] = new OtlpTelemetryDataJson
                {
                    ResourceLogs = group.ToArray()
                };
            }
        }
    }
 
    private static async Task AddTracesAsync(
        ExportArchive exportArchive,
        HttpClient client,
        string baseUrl,
        List<string>? resolvedResources,
        IReadOnlyList<IOtlpResource> allOtlpResources,
        CancellationToken cancellationToken)
    {
        var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources);
        var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
        response.EnsureSuccessStatusCode();
 
        var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        var apiResponse = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        if (apiResponse?.Data?.ResourceSpans is { Length: > 0 })
        {
            // Group by resolved resource name so each resource gets its own file
            var groups = apiResponse.Data.ResourceSpans
                .GroupBy(rs => TelemetryCommandHelpers.ResolveResourceName(rs.Resource, allOtlpResources));
 
            foreach (var group in groups)
            {
                exportArchive.Traces[group.Key] = new OtlpTelemetryDataJson
                {
                    ResourceSpans = group.ToArray()
                };
            }
        }
    }
}