File: Commands\TelemetryTracesCommand.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 Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Command to view traces from the Dashboard telemetry API.
/// </summary>
internal sealed class TelemetryTracesCommand : BaseCommand
{
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<TelemetryTracesCommand> _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<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", "-t");
    private static readonly Option<bool?> s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption();
 
    public TelemetryTracesCommand(
        IInteractionService interactionService,
        IAuxiliaryBackchannelMonitor backchannelMonitor,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        AspireCliTelemetry telemetry,
        IHttpClientFactory httpClientFactory,
        ILogger<TelemetryTracesCommand> logger)
        : base("traces", TelemetryCommandStrings.TracesDescription, 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_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 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;
        }
 
        if (!string.IsNullOrEmpty(traceId))
        {
            return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, cancellationToken);
        }
        else
        {
            return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, cancellationToken);
        }
    }
 
    private async Task<int> FetchSingleTraceAsync(
        string baseUrl,
        string apiToken,
        string traceId,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
 
        var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId);
 
        _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url);
 
        try
        {
            var response = await client.GetAsync(url, cancellationToken);
 
            if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
            {
                _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId));
                return ExitCodeConstants.InvalidCommand;
            }
 
            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
            {
                DisplayTraceDetails(json, traceId);
            }
 
            return ExitCodeConstants.Success;
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Failed to fetch trace from Dashboard API");
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
            return ExitCodeConstants.DashboardFailure;
        }
    }
 
    private async Task<int> FetchTracesAsync(
        string baseUrl,
        string apiToken,
        string? resource,
        bool? hasError,
        int? limit,
        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)>();
        if (hasError.HasValue)
        {
            additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant()));
        }
        if (limit.HasValue)
        {
            additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
        }
 
        var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
 
        _logger.LogDebug("Fetching traces from {Url}", url);
 
        try
        {
            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
            {
                DisplayTracesTable(json);
            }
 
            return ExitCodeConstants.Success;
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Failed to fetch traces from Dashboard API");
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
            return ExitCodeConstants.DashboardFailure;
        }
    }
 
    private static void DisplayTracesTable(string json)
    {
        var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
        var resourceSpans = response?.Data?.ResourceSpans;
 
        if (resourceSpans is null or { Length: 0 })
        {
            TelemetryCommandHelpers.DisplayNoData("traces");
            return;
        }
 
        var table = new Table();
        table.AddColumn("Trace ID");
        table.AddColumn("Resource");
        table.AddColumn("Duration");
        table.AddColumn("Spans");
        table.AddColumn("Status");
 
        // Group by traceId to show trace summary
        var traceInfos = new Dictionary<string, (string Resource, TimeSpan Duration, int SpanCount, bool HasError)>();
 
        foreach (var resourceSpan in resourceSpans)
        {
            var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
 
            foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
            {
                foreach (var span in scopeSpan.Spans ?? [])
                {
                    var traceIdValue = span.TraceId ?? "";
 
                    if (string.IsNullOrEmpty(traceIdValue))
                    {
                        continue;
                    }
 
                    var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano);
                    var hasError = span.Status?.Code == 2; // ERROR status
 
                    if (traceInfos.TryGetValue(traceIdValue, out var info))
                    {
                        var maxDuration = info.Duration > duration ? info.Duration : duration;
                        traceInfos[traceIdValue] = (info.Resource, maxDuration, info.SpanCount + 1, info.HasError || hasError);
                    }
                    else
                    {
                        traceInfos[traceIdValue] = (resourceName, duration, 1, hasError);
                    }
                }
            }
        }
 
        foreach (var (traceIdKey, info) in traceInfos.OrderByDescending(x => x.Value.Duration))
        {
            var statusText = info.HasError ? "[red]ERR[/]" : "[green]OK[/]";
            var durationStr = TelemetryCommandHelpers.FormatDuration(info.Duration);
            table.AddRow(traceIdKey, info.Resource, durationStr, info.SpanCount.ToString(CultureInfo.InvariantCulture), statusText);
        }
 
        AnsiConsole.Write(table);
        AnsiConsole.MarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]");
    }
 
    private static void DisplayTraceDetails(string json, string traceId)
    {
        var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
        var resourceSpans = response?.Data?.ResourceSpans;
 
        // Collect all spans with their metadata
        var spans = new List<SpanInfo>();
 
        foreach (var resourceSpan in resourceSpans ?? [])
        {
            var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
 
            foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
            {
                foreach (var span in scopeSpan.Spans ?? [])
                {
                    var spanId = span.SpanId ?? "";
                    var parentSpanId = span.ParentSpanId;
                    var name = span.Name ?? "";
                    var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano);
                    var startNano = (long)(span.StartTimeUnixNano ?? 0);
                    var hasError = span.Status?.Code == 2; // ERROR status
 
                    spans.Add(new SpanInfo(spanId, parentSpanId, resourceName, name, duration, startNano, hasError));
                }
            }
        }
 
        if (spans.Count == 0)
        {
            AnsiConsole.MarkupLine($"[bold]Trace: {traceId}[/]");
            AnsiConsole.MarkupLine("[dim]No spans found[/]");
            return;
        }
 
        // Calculate total duration from root spans
        var rootSpans = spans.Where(s => string.IsNullOrEmpty(s.ParentSpanId)).ToList();
        var totalDuration = rootSpans.Count > 0 ? rootSpans.Max(s => s.Duration) : spans.Max(s => s.Duration);
 
        // Header
        AnsiConsole.MarkupLine($"[bold]Trace:[/] {traceId}");
        AnsiConsole.MarkupLine($"[bold]Duration:[/] {TelemetryCommandHelpers.FormatDuration(totalDuration)}  [bold]Spans:[/] {spans.Count}");
        AnsiConsole.WriteLine();
 
        // Build tree and display
        DisplaySpanTree(spans);
    }
 
    private static void DisplaySpanTree(List<SpanInfo> spans)
    {
        // Build a lookup of children by parent ID
        var childrenByParent = spans
            .Where(s => !string.IsNullOrEmpty(s.ParentSpanId))
            .GroupBy(s => s.ParentSpanId!)
            .ToDictionary(g => g.Key, g => g.OrderBy(s => s.StartNano).ToList());
 
        // Find root spans (no parent or parent not in this trace)
        var spanIds = spans.Select(s => s.SpanId).ToHashSet();
        var rootSpans = spans
            .Where(s => string.IsNullOrEmpty(s.ParentSpanId) || !spanIds.Contains(s.ParentSpanId!))
            .OrderBy(s => s.StartNano)
            .ToList();
 
        // Track which resources we've seen to show resource transitions
        string? lastResource = null;
 
        foreach (var root in rootSpans)
        {
            DisplaySpanNode(root, childrenByParent, "", true, ref lastResource);
        }
    }
 
    private static void DisplaySpanNode(
        SpanInfo span,
        Dictionary<string, List<SpanInfo>> childrenByParent,
        string indent,
        bool isLast,
        ref string? lastResource)
    {
        // Show resource name when it changes (indicates cross-service call)
        if (span.ResourceName != lastResource)
        {
            if (lastResource != null)
            {
                AnsiConsole.WriteLine(); // Blank line between resources
            }
            AnsiConsole.MarkupLine($"{indent}[bold blue]{span.ResourceName.EscapeMarkup()}[/]");
            lastResource = span.ResourceName;
        }
 
        // Build the connector
        var connector = isLast ? "└─" : "├─";
        var childIndent = indent + (isLast ? "   " : "│  ");
 
        // Format span line with spanId
        var statusColor = span.HasError ? "red" : "green";
        var statusText = span.HasError ? "ERR" : "OK";
        var durationStr = TelemetryCommandHelpers.FormatDuration(span.Duration).PadLeft(8);
        var shortenedSpanId = OtlpHelpers.ToShortenedId(span.SpanId);
        var escapedName = span.Name.EscapeMarkup();
 
        // Truncate long names
        var maxNameLength = 50;
        var displayName = escapedName.Length > maxNameLength
            ? escapedName[..(maxNameLength - 3)] + "..."
            : escapedName;
 
        AnsiConsole.MarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]");
 
        // Render children
        if (childrenByParent.TryGetValue(span.SpanId, out var children))
        {
            for (var i = 0; i < children.Count; i++)
            {
                DisplaySpanNode(children[i], childrenByParent, childIndent, i == children.Count - 1, ref lastResource);
            }
        }
    }
 
    private sealed record SpanInfo(
        string SpanId,
        string? ParentSpanId,
        string ResourceName,
        string Name,
        TimeSpan Duration,
        long StartNano,
        bool HasError);
}