Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Aspire.Cli/Aspire.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
<Compile Include="$(SharedDir)Model\KnownResourceTypes.cs" Link="Model\KnownResourceTypes.cs" />
<Compile Include="$(SharedDir)Model\ResourceSourceViewModel.cs" Link="Model\ResourceSourceViewModel.cs" />
<Compile Include="$(SharedDir)DashboardUrls.cs" Link="Utils\DashboardUrls.cs" />
<Compile Include="$(SharedDir)FormatHelpers.cs" Link="Utils\FormatHelpers.cs" />
<Compile Include="$(SharedDir)TimeProviderExtensions.cs" Link="Extensions\TimeProviderExtensions.cs" />
<Compile Include="$(SharedDir)UserSecrets\UserSecretsPathHelper.cs" Link="Utils\UserSecretsPathHelper.cs" />
<Compile Include="$(SharedDir)UserSecrets\IsolatedUserSecretsHelper.cs" Link="Utils\IsolatedUserSecretsHelper.cs" />
<Compile Include="$(SharedDir)UserSecrets\SecretsStore.cs" Link="Secrets\SecretsStore.cs" />
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Commands/DescribeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal sealed class DescribeCommand : BaseCommand

private readonly IInteractionService _interactionService;
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ResourceColorMap _resourceColorMap = new();
private readonly ResourceColorMap _resourceColorMap;

private static readonly Argument<string?> s_resourceArgument = new("resource")
{
Expand All @@ -95,11 +95,13 @@ public DescribeCommand(
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
ResourceColorMap resourceColorMap,
ILogger<DescribeCommand> logger)
: base("describe", DescribeCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
Aliases.Add("resources");
_interactionService = interactionService;
_resourceColorMap = resourceColorMap;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);

Arguments.Add(s_resourceArgument);
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Cli/Commands/LogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ internal sealed class LogsCommand : BaseCommand
Description = LogsCommandStrings.TimestampsOptionDescription
};

private readonly ResourceColorMap _resourceColorMap = new();
private readonly ResourceColorMap _resourceColorMap;

public LogsCommand(
IInteractionService interactionService,
Expand All @@ -110,9 +110,11 @@ public LogsCommand(
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
ResourceColorMap resourceColorMap,
ILogger<LogsCommand> logger)
: base("logs", LogsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
_resourceColorMap = resourceColorMap;
_interactionService = interactionService;
_logger = logger;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
Expand Down
52 changes: 50 additions & 2 deletions src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Aspire.Cli.Resources;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
using Aspire.Shared;
using Spectre.Console;

Expand Down Expand Up @@ -215,10 +216,11 @@ public static async Task<ResourceInfoJson[]> GetAllResourcesAsync(HttpClient cli
/// <summary>
/// Displays a "no data found" message with consistent styling.
/// </summary>
/// <param name="interactionService">The interaction service for output.</param>
/// <param name="dataType">The type of data (e.g., "logs", "spans", "traces").</param>
public static void DisplayNoData(string dataType)
public static void DisplayNoData(IInteractionService interactionService, string dataType)
{
AnsiConsole.MarkupLine($"[yellow]No {dataType} found[/]");
interactionService.DisplayMarkupLine($"[yellow]No {dataType} found[/]");
}

/// <summary>
Expand Down Expand Up @@ -249,6 +251,24 @@ public static string FormatDuration(TimeSpan duration)
return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture);
}

/// <summary>
/// Gets abbreviated severity text for an OTLP 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 string GetSeverityText(int? severityNumber)
{
return severityNumber switch
{
>= 21 => "CRIT",
>= 17 => "FAIL",
>= 13 => "WARN",
>= 9 => "INFO",
>= 5 => "DBUG",
>= 1 => "TRCE",
_ => "-"
};
}

/// <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
Expand Down Expand Up @@ -287,4 +307,32 @@ public static async IAsyncEnumerable<string> ReadLinesAsync(
}
}
}

/// <summary>
/// Converts an array of <see cref="ResourceInfoJson"/> to a list of <see cref="IOtlpResource"/> for use with <see cref="OtlpHelpers.GetResourceName"/>.
/// </summary>
public static IReadOnlyList<IOtlpResource> ToOtlpResources(ResourceInfoJson[] resources)
{
var result = new IOtlpResource[resources.Length];
for (var i = 0; i < resources.Length; i++)
{
result[i] = new SimpleOtlpResource(resources[i].Name, resources[i].InstanceId);
}
return result;
}

/// <summary>
/// Resolves the display name for an OTLP resource using <see cref="OtlpHelpers.GetResourceName"/>,
/// appending a shortened instance ID when there are replicas with the same base name.
/// </summary>
public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyList<IOtlpResource> allResources)
{
if (resource is null)
{
return "unknown";
}

var otlpResource = new SimpleOtlpResource(resource.GetServiceName(), resource.GetServiceInstanceId());
return OtlpHelpers.GetResourceName(otlpResource, allResources);
}
}
45 changes: 28 additions & 17 deletions src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ internal sealed class TelemetryLogsCommand : BaseCommand
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger<TelemetryLogsCommand> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ResourceColorMap _resourceColorMap;
private readonly TimeProvider _timeProvider;

// Shared options from TelemetryCommandHelpers
private static readonly Argument<string?> s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument();
Expand All @@ -50,11 +52,15 @@ public TelemetryLogsCommand(
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
IHttpClientFactory httpClientFactory,
ResourceColorMap resourceColorMap,
TimeProvider timeProvider,
ILogger<TelemetryLogsCommand> logger)
: base("logs", TelemetryCommandStrings.LogsDescription, features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_httpClientFactory = httpClientFactory;
_resourceColorMap = resourceColorMap;
_timeProvider = timeProvider;
_logger = logger;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);

Expand Down Expand Up @@ -120,6 +126,8 @@ private async Task<int> FetchLogsAsync(
return ExitCodeConstants.InvalidCommand;
}

var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);

// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>
{
Expand All @@ -141,11 +149,11 @@ private async Task<int> FetchLogsAsync(
{
if (follow)
{
return await StreamLogsAsync(client, url, format, cancellationToken);
return await StreamLogsAsync(client, url, format, allOtlpResources, cancellationToken);
}
else
{
return await GetLogsSnapshotAsync(client, url, format, cancellationToken);
return await GetLogsSnapshotAsync(client, url, format, allOtlpResources, cancellationToken);
}
}
catch (HttpRequestException ex)
Expand All @@ -156,7 +164,7 @@ private async Task<int> FetchLogsAsync(
}
}

private async Task<int> GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
private async Task<int> GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList<IOtlpResource> allResources, CancellationToken cancellationToken)
{
var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
Expand All @@ -176,13 +184,13 @@ private async Task<int> GetLogsSnapshotAsync(HttpClient client, string url, Outp
}
else
{
DisplayLogsSnapshot(json);
DisplayLogsSnapshot(json, allResources);
}

return ExitCodeConstants.Success;
}

private async Task<int> StreamLogsAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
private async Task<int> StreamLogsAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList<IOtlpResource> allResources, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
Expand All @@ -205,38 +213,38 @@ private async Task<int> StreamLogsAsync(HttpClient client, string url, OutputFor
}
else
{
DisplayLogsStreamLine(line);
DisplayLogsStreamLine(line, allResources);
}
}

return ExitCodeConstants.Success;
}

private static void DisplayLogsSnapshot(string json)
private void DisplayLogsSnapshot(string json, IReadOnlyList<IOtlpResource> allResources)
{
var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
var resourceLogs = response?.Data?.ResourceLogs;

if (resourceLogs is null or { Length: 0 })
{
TelemetryCommandHelpers.DisplayNoData("logs");
TelemetryCommandHelpers.DisplayNoData(_interactionService, "logs");
return;
}

DisplayResourceLogs(resourceLogs);
DisplayResourceLogs(resourceLogs, allResources);
}

private static void DisplayLogsStreamLine(string json)
private void DisplayLogsStreamLine(string json, IReadOnlyList<IOtlpResource> allResources)
{
var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportLogsServiceRequestJson);
DisplayResourceLogs(request?.ResourceLogs ?? []);
DisplayResourceLogs(request?.ResourceLogs ?? [], allResources);
}

private static void DisplayResourceLogs(IEnumerable<OtlpResourceLogsJson> resourceLogs)
private void DisplayResourceLogs(IEnumerable<OtlpResourceLogsJson> resourceLogs, IReadOnlyList<IOtlpResource> allResources)
{
foreach (var resourceLog in resourceLogs)
{
var resourceName = resourceLog.Resource?.GetServiceName() ?? "unknown";
var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceLog.Resource, allResources);

foreach (var scopeLog in resourceLog.ScopeLogs ?? [])
{
Expand All @@ -250,16 +258,19 @@ private static void DisplayResourceLogs(IEnumerable<OtlpResourceLogsJson> resour

// 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)
private void DisplayLogEntry(string resourceName, OtlpLogRecordJson log)
{
var timestamp = OtlpHelpers.FormatNanoTimestamp(log.TimeUnixNano);
var severity = log.SeverityText ?? "";
var timestamp = log.TimeUnixNano.HasValue
? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(log.TimeUnixNano.Value))
: "";
var severity = TelemetryCommandHelpers.GetSeverityText(log.SeverityNumber);
var body = log.Body?.StringValue ?? "";

// Use severity number for color mapping (more reliable than text)
var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber);
var resourceColor = _resourceColorMap.GetColor(resourceName);

var escapedBody = body.EscapeMarkup();
AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName.EscapeMarkup()}[/] {escapedBody}");
_interactionService.DisplayMarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-4}[/] [{resourceColor}]{resourceName.EscapeMarkup()}[/] {escapedBody}");
}
}
Loading
Loading