File: Commands\TelemetryCommandHelpers.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.Interaction;
using Aspire.Cli.Otlp;
using Aspire.Cli.Resources;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Shared;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Shared helper methods for telemetry commands.
/// </summary>
internal static class TelemetryCommandHelpers
{
    /// <summary>
    /// HTTP header name for API authentication.
    /// </summary>
    internal const string ApiKeyHeaderName = "X-API-Key";
 
    #region Shared Command Options
 
    /// <summary>
    /// Resource name argument shared across telemetry commands.
    /// </summary>
    internal static Argument<string?> CreateResourceArgument() => new("resource")
    {
        Description = TelemetryCommandStrings.ResourceArgumentDescription,
        Arity = ArgumentArity.ZeroOrOne
    };
 
    /// <summary>
    /// Project option shared across telemetry commands.
    /// </summary>
    internal static Option<FileInfo?> CreateProjectOption() => new("--project")
    {
        Description = TelemetryCommandStrings.ProjectOptionDescription
    };
 
    /// <summary>
    /// Output format option shared across telemetry commands.
    /// </summary>
    internal static Option<OutputFormat> CreateFormatOption() => new("--format")
    {
        Description = TelemetryCommandStrings.FormatOptionDescription
    };
 
    /// <summary>
    /// Limit option shared across telemetry commands.
    /// </summary>
    internal static Option<int?> CreateLimitOption() => new("--limit", "-n")
    {
        Description = TelemetryCommandStrings.LimitOptionDescription
    };
 
    /// <summary>
    /// Follow/streaming option for logs and spans commands.
    /// </summary>
    internal static Option<bool> CreateFollowOption() => new("--follow", "-f")
    {
        Description = TelemetryCommandStrings.FollowOptionDescription
    };
 
    /// <summary>
    /// Trace ID filter option shared across telemetry commands.
    /// </summary>
    internal static Option<string?> CreateTraceIdOption(string name, string? alias = null)
    {
        var option = alias is null ? new Option<string?>(name) : new Option<string?>(name, alias);
        option.Description = TelemetryCommandStrings.TraceIdOptionDescription;
        return option;
    }
 
    /// <summary>
    /// Has error filter option for spans and traces commands.
    /// </summary>
    internal static Option<bool?> CreateHasErrorOption() => new("--has-error")
    {
        Description = TelemetryCommandStrings.HasErrorOptionDescription
    };
 
    #endregion
 
    /// <summary>
    /// Validates that an HTTP response has a JSON content type.
    /// </summary>
    /// <param name="response">The HTTP response to validate.</param>
    /// <returns>True if the response has a JSON content type; false otherwise.</returns>
    public static bool HasJsonContentType(HttpResponseMessage response)
    {
        var mediaType = response.Content.Headers.ContentType?.MediaType;
        return mediaType is "application/json" or "text/json" or "application/x-ndjson";
    }
 
    /// <summary>
    /// Resolves an AppHost connection and gets Dashboard API info.
    /// </summary>
    /// <returns>A tuple with success status, base URL, API token, dashboard UI URL, and exit code if failed.</returns>
    public static async Task<(bool Success, string? BaseUrl, string? ApiToken, string? DashboardUrl, int ExitCode)> GetDashboardApiAsync(
        AppHostConnectionResolver connectionResolver,
        IInteractionService interactionService,
        FileInfo? projectFile,
        OutputFormat format,
        CancellationToken cancellationToken)
    {
        // When outputting JSON, suppress status messages to keep output machine-readable
        var scanningMessage = format == OutputFormat.Json ? string.Empty : TelemetryCommandStrings.ScanningForRunningAppHosts;
 
        var result = await connectionResolver.ResolveConnectionAsync(
            projectFile,
            scanningMessage,
            TelemetryCommandStrings.SelectAppHost,
            TelemetryCommandStrings.NoInScopeAppHostsShowingAll,
            TelemetryCommandStrings.AppHostNotRunning,
            cancellationToken);
 
        if (!result.Success)
        {
            return (false, null, null, null, ExitCodeConstants.Success);
        }
 
        var dashboardInfo = await result.Connection!.GetDashboardInfoV2Async(cancellationToken);
        if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null)
        {
            interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable);
            return (false, null, null, null, ExitCodeConstants.DashboardFailure);
        }
 
        // Extract dashboard base URL (without /login path) for hyperlinks
        var dashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault());
 
        return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, dashboardUrl, 0);
    }
 
    /// <summary>
    /// Extracts the base URL from a dashboard URL (removes /login?t=... path).
    /// </summary>
    private static string? ExtractDashboardBaseUrl(string? dashboardUrlWithToken)
    {
        if (string.IsNullOrEmpty(dashboardUrlWithToken))
        {
            return null;
        }
 
        // Dashboard URLs look like: http://localhost:18888/login?t=abcd1234
        // We want: http://localhost:18888
        var uri = new Uri(dashboardUrlWithToken);
        return $"{uri.Scheme}://{uri.Authority}";
    }
 
    /// <summary>
    /// Creates an HTTP client configured for Dashboard API access.
    /// </summary>
    public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiToken)
    {
        var client = factory.CreateClient();
        client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken);
        return client;
    }
 
    /// <summary>
    /// Fetches available resources from the Dashboard API and resolves a resource name to specific instances.
    /// If the resource name matches a base name with multiple replicas, returns all matching replica names.
    /// </summary>
    /// <param name="client">The HTTP client configured for Dashboard API access.</param>
    /// <param name="baseUrl">The Dashboard API base URL.</param>
    /// <param name="resourceName">The resource name to resolve (can be base name or full instance name).</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A list of resolved resource display names to query, or null if resource not found.</returns>
    public static async Task<List<string>?> ResolveResourceNamesAsync(
        HttpClient client,
        string baseUrl,
        string? resourceName,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(resourceName))
        {
            // No filter - return null to indicate no resource filter
            return null;
        }
 
        // Fetch available resources
        var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl);
        var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
        response.EnsureSuccessStatusCode();
 
        var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        var resources = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray);
 
        if (resources is null || resources.Length == 0)
        {
            return null;
        }
 
        // First, try exact match on display name (full instance name like "catalogservice-abc123")
        var exactMatch = resources.FirstOrDefault(r =>
            string.Equals(r.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase));
        if (exactMatch is not null)
        {
            return [exactMatch.DisplayName];
        }
 
        // Then, try matching by base name to find all replicas
        var matchingReplicas = resources
            .Where(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase))
            .Select(r => r.DisplayName)
            .ToList();
 
        if (matchingReplicas.Count > 0)
        {
            return matchingReplicas;
        }
 
        // No match found
        return [];
    }
 
    /// <summary>
    /// Displays a "no data found" message with consistent styling.
    /// </summary>
    /// <param name="dataType">The type of data (e.g., "logs", "spans", "traces").</param>
    public static void DisplayNoData(string dataType)
    {
        AnsiConsole.MarkupLine($"[yellow]No {dataType} found[/]");
    }
 
    /// <summary>
    /// Creates a Spectre Console hyperlink markup for a trace detail in the Dashboard.
    /// </summary>
    /// <param name="dashboardUrl">The base dashboard URL.</param>
    /// <param name="traceId">The trace ID.</param>
    /// <param name="displayText">The text to display (defaults to shortened trace ID).</param>
    /// <returns>A Spectre markup string with hyperlink, or plain text if dashboardUrl is null.</returns>
    public static string FormatTraceLink(string? dashboardUrl, string traceId, string? displayText = null)
    {
        var text = displayText ?? OtlpHelpers.ToShortenedId(traceId);
        if (string.IsNullOrEmpty(dashboardUrl))
        {
            return text;
        }
 
        // Dashboard trace detail URL: /traces/detail/{traceId}
        var url = DashboardUrls.CombineUrl(dashboardUrl, DashboardUrls.TraceDetailUrl(traceId));
        return $"[link={url}]{text}[/]";
    }
 
    /// <summary>
    /// Formats a duration using the shared DurationFormatter.
    /// </summary>
    public static string FormatDuration(TimeSpan duration)
    {
        return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture);
    }
 
    /// <summary>
    /// Gets Spectre Console color for a log severity number.
    /// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL
    /// </summary>
    public static Color GetSeverityColor(int? severityNumber)
    {
        return severityNumber switch
        {
            >= 17 => Color.Red,      // ERROR/FATAL
            >= 13 => Color.Yellow,   // WARN
            >= 9 => Color.Blue,      // INFO
            >= 5 => Color.Grey,      // DEBUG
            >= 1 => Color.Grey,      // TRACE
            _ => Color.White
        };
    }
 
    /// <summary>
    /// Reads lines from an HTTP streaming response, yielding each complete line as it arrives.
    /// </summary>
    public static async IAsyncEnumerable<string> ReadLinesAsync(
        this StreamReader reader,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
            if (line is null)
            {
                yield break;
            }
 
            if (!string.IsNullOrEmpty(line))
            {
                yield return line;
            }
        }
    }
}