File: Commands\TelemetrySpansCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Otlp;
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 Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Command to view spans from the Dashboard telemetry API.
/// </summary>
internal sealed class TelemetrySpansCommand : BaseCommand
{
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<TelemetrySpansCommand> _logger;
    private readonly IHttpClientFactory _httpClientFactory;
 
    // Shared options from TelemetryCommandHelpers
    private static readonly Argument<string?> s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument();
    private static readonly Option<FileInfo?> s_projectOption = TelemetryCommandHelpers.CreateProjectOption();
    private static readonly Option<bool> s_followOption = TelemetryCommandHelpers.CreateFollowOption();
    private static readonly Option<OutputFormat> s_formatOption = TelemetryCommandHelpers.CreateFormatOption();
    private static readonly Option<int?> s_limitOption = TelemetryCommandHelpers.CreateLimitOption();
    private static readonly Option<string?> s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id");
    private static readonly Option<bool?> s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption();
 
    public TelemetrySpansCommand(
        IInteractionService interactionService,
        IAuxiliaryBackchannelMonitor backchannelMonitor,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        AspireCliTelemetry telemetry,
        IHttpClientFactory httpClientFactory,
        ILogger<TelemetrySpansCommand> logger)
        : base("spans", TelemetryCommandStrings.SpansDescription, features, updateNotifier, executionContext, interactionService, telemetry)
    {
        _interactionService = interactionService;
        _httpClientFactory = httpClientFactory;
        _logger = logger;
        _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
 
        Arguments.Add(s_resourceArgument);
        Options.Add(s_projectOption);
        Options.Add(s_followOption);
        Options.Add(s_formatOption);
        Options.Add(s_limitOption);
        Options.Add(s_traceIdOption);
        Options.Add(s_hasErrorOption);
    }
 
    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_projectOption);
        var follow = parseResult.GetValue(s_followOption);
        var format = parseResult.GetValue(s_formatOption);
        var limit = parseResult.GetValue(s_limitOption);
        var traceId = parseResult.GetValue(s_traceIdOption);
        var hasError = parseResult.GetValue(s_hasErrorOption);
 
        // Validate --limit value
        if (limit.HasValue && limit.Value < 1)
        {
            _interactionService.DisplayError(TelemetryCommandStrings.LimitMustBePositive);
            return ExitCodeConstants.InvalidCommand;
        }
 
        var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync(
            _connectionResolver, _interactionService, passedAppHostProjectFile, format, cancellationToken);
 
        if (!success)
        {
            return exitCode;
        }
 
        return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, cancellationToken);
    }
 
    private async Task<int> FetchSpansAsync(
        string baseUrl,
        string apiToken,
        string? resource,
        string? traceId,
        bool? hasError,
        int? limit,
        bool follow,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
 
        // Resolve resource name to specific instances (handles replicas)
        var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync(
            client, baseUrl, resource, cancellationToken);
 
        // If a resource was specified but not found, show error
        if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0)
        {
            _interactionService.DisplayError($"Resource '{resource}' not found.");
            return ExitCodeConstants.InvalidCommand;
        }
 
        // Build query string with multiple resource parameters
        var additionalParams = new List<(string key, string? value)>
        {
            ("traceId", traceId)
        };
        if (hasError.HasValue)
        {
            additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant()));
        }
        if (limit.HasValue && !follow)
        {
            additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
        }
        if (follow)
        {
            additionalParams.Add(("follow", "true"));
        }
 
        var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
 
        _logger.LogDebug("Fetching spans from {Url}", url);
 
        try
        {
            if (follow)
            {
                return await StreamSpansAsync(client, url, format, cancellationToken);
            }
            else
            {
                return await GetSpansSnapshotAsync(client, url, format, cancellationToken);
            }
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Failed to fetch spans from Dashboard API");
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
            return ExitCodeConstants.DashboardFailure;
        }
    }
 
    private async Task<int> GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
    {
        var response = await client.GetAsync(url, cancellationToken);
        response.EnsureSuccessStatusCode();
 
        if (!TelemetryCommandHelpers.HasJsonContentType(response))
        {
            _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType);
            return ExitCodeConstants.DashboardFailure;
        }
 
        var json = await response.Content.ReadAsStringAsync(cancellationToken);
 
        if (format == OutputFormat.Json)
        {
            _interactionService.DisplayRawText(json);
        }
        else
        {
            // Parse OTLP JSON and display in table format
            DisplaySpansSnapshot(json);
        }
 
        return ExitCodeConstants.Success;
    }
 
    private async Task<int> StreamSpansAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
    {
        using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        response.EnsureSuccessStatusCode();
 
        if (!TelemetryCommandHelpers.HasJsonContentType(response))
        {
            _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType);
            return ExitCodeConstants.DashboardFailure;
        }
 
        using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new StreamReader(stream);
 
        await foreach (var line in reader.ReadLinesAsync(cancellationToken))
        {
            if (format == OutputFormat.Json)
            {
                _interactionService.DisplayRawText(line);
            }
            else
            {
                DisplaySpansStreamLine(line);
            }
        }
 
        return ExitCodeConstants.Success;
    }
 
    private static void DisplaySpansSnapshot(string json)
    {
        var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
        var resourceSpans = response?.Data?.ResourceSpans;
 
        if (resourceSpans is null or { Length: 0 })
        {
            TelemetryCommandHelpers.DisplayNoData("spans");
            return;
        }
 
        DisplayResourceSpans(resourceSpans);
    }
 
    private static void DisplaySpansStreamLine(string json)
    {
        var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportTraceServiceRequestJson);
        DisplayResourceSpans(request?.ResourceSpans ?? []);
    }
 
    private static void DisplayResourceSpans(IEnumerable<OtlpResourceSpansJson> resourceSpans)
    {
        foreach (var resourceSpan in resourceSpans)
        {
            var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
 
            foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
            {
                foreach (var span in scopeSpan.Spans ?? [])
                {
                    DisplaySpanEntry(resourceName, span);
                }
            }
        }
    }
 
    // Using simple text lines instead of Spectre.Console Table for streaming support.
    // Tables require knowing all data upfront, but streaming mode displays spans as they arrive.
    private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span)
    {
        var name = span.Name ?? "";
        var spanId = span.SpanId ?? "";
        var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano);
        var hasError = span.Status?.Code == 2; // ERROR status
 
        var statusColor = hasError ? Color.Red : Color.Green;
        var statusText = hasError ? "ERR" : "OK";
 
        var shortSpanId = OtlpHelpers.ToShortenedId(spanId);
        var durationStr = TelemetryCommandHelpers.FormatDuration(duration);
 
        var escapedName = name.EscapeMarkup();
        AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName,-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}");
    }
}