File: Commands\TelemetryLogsCommand.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 structured logs from the Dashboard telemetry API.
/// </summary>
internal sealed class TelemetryLogsCommand : BaseCommand
{
    private readonly IInteractionService _interactionService;
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly ILogger<TelemetryLogsCommand> _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");
    // Logs-specific option
    private static readonly Option<string?> s_severityOption = new("--severity")
    {
        Description = TelemetryCommandStrings.SeverityOptionDescription
    };
 
    public TelemetryLogsCommand(
        IInteractionService interactionService,
        IAuxiliaryBackchannelMonitor backchannelMonitor,
        IFeatures features,
        ICliUpdateNotifier updateNotifier,
        CliExecutionContext executionContext,
        AspireCliTelemetry telemetry,
        IHttpClientFactory httpClientFactory,
        ILogger<TelemetryLogsCommand> logger)
        : base("logs", TelemetryCommandStrings.LogsDescription, 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_severityOption);
    }
 
    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 severity = parseResult.GetValue(s_severityOption);
 
        // 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 FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, cancellationToken);
    }
 
    private async Task<int> FetchLogsAsync(
        string baseUrl,
        string apiToken,
        string? resource,
        string? traceId,
        string? severity,
        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),
            ("severity", severity)
        };
        if (limit.HasValue && !follow)
        {
            additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture)));
        }
        if (follow)
        {
            additionalParams.Add(("follow", "true"));
        }
 
        var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, [.. additionalParams]);
 
        try
        {
            if (follow)
            {
                return await StreamLogsAsync(client, url, format, cancellationToken);
            }
            else
            {
                return await GetLogsSnapshotAsync(client, url, format, cancellationToken);
            }
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Failed to fetch logs from Dashboard API");
            _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
            return ExitCodeConstants.DashboardFailure;
        }
    }
 
    private async Task<int> GetLogsSnapshotAsync(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
        {
            DisplayLogsSnapshot(json);
        }
 
        return ExitCodeConstants.Success;
    }
 
    private async Task<int> StreamLogsAsync(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
            {
                DisplayLogsStreamLine(line);
            }
        }
 
        return ExitCodeConstants.Success;
    }
 
    private static void DisplayLogsSnapshot(string json)
    {
        var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
        var resourceLogs = response?.Data?.ResourceLogs;
 
        if (resourceLogs is null or { Length: 0 })
        {
            TelemetryCommandHelpers.DisplayNoData("logs");
            return;
        }
 
        DisplayResourceLogs(resourceLogs);
    }
 
    private static void DisplayLogsStreamLine(string json)
    {
        var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportLogsServiceRequestJson);
        DisplayResourceLogs(request?.ResourceLogs ?? []);
    }
 
    private static void DisplayResourceLogs(IEnumerable<OtlpResourceLogsJson> resourceLogs)
    {
        foreach (var resourceLog in resourceLogs)
        {
            var resourceName = resourceLog.Resource?.GetServiceName() ?? "unknown";
 
            foreach (var scopeLog in resourceLog.ScopeLogs ?? [])
            {
                foreach (var log in scopeLog.LogRecords ?? [])
                {
                    DisplayLogEntry(resourceName, log);
                }
            }
        }
    }
 
    // Using simple text lines instead of Spectre.Console Table for streaming support.
    // Tables require knowing all data upfront, but streaming mode displays logs as they arrive.
    private static void DisplayLogEntry(string resourceName, OtlpLogRecordJson log)
    {
        var timestamp = OtlpHelpers.FormatNanoTimestamp(log.TimeUnixNano);
        var severity = log.SeverityText ?? "";
        var body = log.Body?.StringValue ?? "";
 
        // Use severity number for color mapping (more reliable than text)
        var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber);
 
        var escapedBody = body.EscapeMarkup();
        AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName}[/] {escapedBody}");
    }
}