diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index 6944c894d90..e6d7574c02c 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -87,6 +87,8 @@
+
+
diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs
index ee63be937a4..a0f5469655d 100644
--- a/src/Aspire.Cli/Commands/DescribeCommand.cs
+++ b/src/Aspire.Cli/Commands/DescribeCommand.cs
@@ -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 s_resourceArgument = new("resource")
{
@@ -95,11 +95,13 @@ public DescribeCommand(
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
+ ResourceColorMap resourceColorMap,
ILogger 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);
diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs
index 4d46b8e94c0..9f741c8c65e 100644
--- a/src/Aspire.Cli/Commands/LogsCommand.cs
+++ b/src/Aspire.Cli/Commands/LogsCommand.cs
@@ -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,
@@ -110,9 +110,11 @@ public LogsCommand(
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
+ ResourceColorMap resourceColorMap,
ILogger logger)
: base("logs", LogsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry)
{
+ _resourceColorMap = resourceColorMap;
_interactionService = interactionService;
_logger = logger;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
index 7f782b9e0be..03ff68822be 100644
--- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
+++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs
@@ -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;
@@ -215,10 +216,11 @@ public static async Task GetAllResourcesAsync(HttpClient cli
///
/// Displays a "no data found" message with consistent styling.
///
+ /// The interaction service for output.
/// The type of data (e.g., "logs", "spans", "traces").
- 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[/]");
}
///
@@ -249,6 +251,24 @@ public static string FormatDuration(TimeSpan duration)
return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture);
}
+ ///
+ /// 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
+ ///
+ public static string GetSeverityText(int? severityNumber)
+ {
+ return severityNumber switch
+ {
+ >= 21 => "CRIT",
+ >= 17 => "FAIL",
+ >= 13 => "WARN",
+ >= 9 => "INFO",
+ >= 5 => "DBUG",
+ >= 1 => "TRCE",
+ _ => "-"
+ };
+ }
+
///
/// 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
@@ -287,4 +307,32 @@ public static async IAsyncEnumerable ReadLinesAsync(
}
}
}
+
+ ///
+ /// Converts an array of to a list of for use with .
+ ///
+ public static IReadOnlyList 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;
+ }
+
+ ///
+ /// Resolves the display name for an OTLP resource using ,
+ /// appending a shortened instance ID when there are replicas with the same base name.
+ ///
+ public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyList allResources)
+ {
+ if (resource is null)
+ {
+ return "unknown";
+ }
+
+ var otlpResource = new SimpleOtlpResource(resource.GetServiceName(), resource.GetServiceInstanceId());
+ return OtlpHelpers.GetResourceName(otlpResource, allResources);
+ }
}
diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
index dc0d0492842..d488952a91e 100644
--- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs
@@ -28,6 +28,8 @@ internal sealed class TelemetryLogsCommand : BaseCommand
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ResourceColorMap _resourceColorMap;
+ private readonly TimeProvider _timeProvider;
// Shared options from TelemetryCommandHelpers
private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument();
@@ -50,11 +52,15 @@ public TelemetryLogsCommand(
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
IHttpClientFactory httpClientFactory,
+ ResourceColorMap resourceColorMap,
+ TimeProvider timeProvider,
ILogger 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);
@@ -120,6 +126,8 @@ private async Task FetchLogsAsync(
return ExitCodeConstants.InvalidCommand;
}
+ var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);
+
// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>
{
@@ -141,11 +149,11 @@ private async Task 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)
@@ -156,7 +164,7 @@ private async Task FetchLogsAsync(
}
}
- private async Task GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
+ private async Task GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken)
{
var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -176,13 +184,13 @@ private async Task GetLogsSnapshotAsync(HttpClient client, string url, Outp
}
else
{
- DisplayLogsSnapshot(json);
+ DisplayLogsSnapshot(json, allResources);
}
return ExitCodeConstants.Success;
}
- private async Task StreamLogsAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
+ private async Task StreamLogsAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -205,38 +213,38 @@ private async Task 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 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 allResources)
{
var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportLogsServiceRequestJson);
- DisplayResourceLogs(request?.ResourceLogs ?? []);
+ DisplayResourceLogs(request?.ResourceLogs ?? [], allResources);
}
- private static void DisplayResourceLogs(IEnumerable resourceLogs)
+ private void DisplayResourceLogs(IEnumerable resourceLogs, IReadOnlyList 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 ?? [])
{
@@ -250,16 +258,19 @@ private static void DisplayResourceLogs(IEnumerable 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}");
}
}
diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
index e9ae5ca8225..6130da4c76f 100644
--- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs
@@ -28,6 +28,8 @@ internal sealed class TelemetrySpansCommand : BaseCommand
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ResourceColorMap _resourceColorMap;
+ private readonly TimeProvider _timeProvider;
// Shared options from TelemetryCommandHelpers
private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument();
@@ -46,11 +48,15 @@ public TelemetrySpansCommand(
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
IHttpClientFactory httpClientFactory,
+ ResourceColorMap resourceColorMap,
+ TimeProvider timeProvider,
ILogger logger)
: base("spans", TelemetryCommandStrings.SpansDescription, features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_httpClientFactory = httpClientFactory;
+ _resourceColorMap = resourceColorMap;
+ _timeProvider = timeProvider;
_logger = logger;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
@@ -116,6 +122,8 @@ private async Task FetchSpansAsync(
return ExitCodeConstants.InvalidCommand;
}
+ var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);
+
// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>
{
@@ -142,11 +150,11 @@ private async Task FetchSpansAsync(
{
if (follow)
{
- return await StreamSpansAsync(client, url, format, cancellationToken);
+ return await StreamSpansAsync(client, url, format, allOtlpResources, cancellationToken);
}
else
{
- return await GetSpansSnapshotAsync(client, url, format, cancellationToken);
+ return await GetSpansSnapshotAsync(client, url, format, allOtlpResources, cancellationToken);
}
}
catch (HttpRequestException ex)
@@ -157,7 +165,7 @@ private async Task FetchSpansAsync(
}
}
- private async Task GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
+ private async Task GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken)
{
var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -177,14 +185,13 @@ private async Task GetSpansSnapshotAsync(HttpClient client, string url, Out
}
else
{
- // Parse OTLP JSON and display in table format
- DisplaySpansSnapshot(json);
+ DisplaySpansSnapshot(json, allResources);
}
return ExitCodeConstants.Success;
}
- private async Task StreamSpansAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken)
+ private async Task StreamSpansAsync(HttpClient client, string url, OutputFormat format, IReadOnlyList allResources, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -207,52 +214,59 @@ private async Task StreamSpansAsync(HttpClient client, string url, OutputFo
}
else
{
- DisplaySpansStreamLine(line);
+ DisplaySpansStreamLine(line, allResources);
}
}
return ExitCodeConstants.Success;
}
- private static void DisplaySpansSnapshot(string json)
+ private void DisplaySpansSnapshot(string json, IReadOnlyList allResources)
{
var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
var resourceSpans = response?.Data?.ResourceSpans;
if (resourceSpans is null or { Length: 0 })
{
- TelemetryCommandHelpers.DisplayNoData("spans");
+ TelemetryCommandHelpers.DisplayNoData(_interactionService, "spans");
return;
}
- DisplayResourceSpans(resourceSpans);
+ DisplayResourceSpans(resourceSpans, allResources);
}
- private static void DisplaySpansStreamLine(string json)
+ private void DisplaySpansStreamLine(string json, IReadOnlyList allResources)
{
var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportTraceServiceRequestJson);
- DisplayResourceSpans(request?.ResourceSpans ?? []);
+ DisplayResourceSpans(request?.ResourceSpans ?? [], allResources);
}
- private static void DisplayResourceSpans(IEnumerable resourceSpans)
+ private void DisplayResourceSpans(IEnumerable resourceSpans, IReadOnlyList allResources)
{
+ var allSpans = new List<(string ResourceName, OtlpSpanJson Span)>();
+
foreach (var resourceSpan in resourceSpans)
{
- var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
+ var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources);
foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
{
foreach (var span in scopeSpan.Spans ?? [])
{
- DisplaySpanEntry(resourceName, span);
+ allSpans.Add((resourceName, span));
}
}
}
+
+ foreach (var (resourceName, span) in allSpans.OrderBy(s => s.Span.StartTimeUnixNano ?? 0))
+ {
+ 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)
+ private void DisplaySpanEntry(string resourceName, OtlpSpanJson span)
{
var name = span.Name ?? "";
var spanId = span.SpanId ?? "";
@@ -260,12 +274,16 @@ private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span)
var hasError = span.Status?.Code == 2; // ERROR status
var statusColor = hasError ? Color.Red : Color.Green;
- var statusText = hasError ? "ERR" : "OK";
+ var statusText = hasError ? "ERR" : "OK ";
+ var timestamp = span.StartTimeUnixNano.HasValue
+ ? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano.Value))
+ : "";
var shortSpanId = OtlpHelpers.ToShortenedId(spanId);
var durationStr = TelemetryCommandHelpers.FormatDuration(duration);
+ var resourceColor = _resourceColorMap.GetColor(resourceName);
var escapedName = name.EscapeMarkup();
- AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName.EscapeMarkup(),-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}");
+ _interactionService.DisplayMarkupLine($"[grey]{timestamp}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] [{resourceColor}]{resourceName.EscapeMarkup()}[/]: {escapedName} [grey]{shortSpanId}[/]");
}
}
diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
index ac44bbba18c..c1808073429 100644
--- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
+++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs
@@ -27,6 +27,8 @@ internal sealed class TelemetryTracesCommand : BaseCommand
private readonly AppHostConnectionResolver _connectionResolver;
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ResourceColorMap _resourceColorMap;
+ private readonly TimeProvider _timeProvider;
// Shared options from TelemetryCommandHelpers
private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument();
@@ -44,11 +46,15 @@ public TelemetryTracesCommand(
CliExecutionContext executionContext,
AspireCliTelemetry telemetry,
IHttpClientFactory httpClientFactory,
+ ResourceColorMap resourceColorMap,
+ TimeProvider timeProvider,
ILogger logger)
: base("traces", TelemetryCommandStrings.TracesDescription, features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_httpClientFactory = httpClientFactory;
+ _resourceColorMap = resourceColorMap;
+ _timeProvider = timeProvider;
_logger = logger;
_connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger);
@@ -105,6 +111,10 @@ private async Task FetchSingleTraceAsync(
{
using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken);
+ // Fetch resources for name resolution
+ var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false);
+ var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);
+
var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId);
_logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url);
@@ -136,7 +146,7 @@ private async Task FetchSingleTraceAsync(
}
else
{
- DisplayTraceDetails(json, traceId);
+ DisplayTraceDetails(json, traceId, allOtlpResources);
}
return ExitCodeConstants.Success;
@@ -170,6 +180,8 @@ private async Task FetchTracesAsync(
return ExitCodeConstants.InvalidCommand;
}
+ var allOtlpResources = TelemetryCommandHelpers.ToOtlpResources(resources);
+
// Build query string with multiple resource parameters
var additionalParams = new List<(string key, string? value)>();
if (hasError.HasValue)
@@ -205,7 +217,7 @@ private async Task FetchTracesAsync(
}
else
{
- DisplayTracesTable(json, _interactionService);
+ DisplayTracesTable(json, allOtlpResources);
}
return ExitCodeConstants.Success;
@@ -218,30 +230,30 @@ private async Task FetchTracesAsync(
}
}
- private static void DisplayTracesTable(string json, IInteractionService interactionService)
+ private void DisplayTracesTable(string json, IReadOnlyList allResources)
{
var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
var resourceSpans = response?.Data?.ResourceSpans;
if (resourceSpans is null or { Length: 0 })
{
- TelemetryCommandHelpers.DisplayNoData("traces");
+ TelemetryCommandHelpers.DisplayNoData(_interactionService, "traces");
return;
}
var table = new Table();
- table.AddBoldColumn(TelemetryCommandStrings.HeaderTraceId);
- table.AddBoldColumn(TelemetryCommandStrings.HeaderResource);
- table.AddBoldColumn(TelemetryCommandStrings.HeaderDuration);
+ table.AddBoldColumn(TelemetryCommandStrings.HeaderTimestamp);
+ table.AddBoldColumn(TelemetryCommandStrings.HeaderName);
table.AddBoldColumn(TelemetryCommandStrings.HeaderSpans);
+ table.AddBoldColumn(TelemetryCommandStrings.HeaderDuration);
table.AddBoldColumn(TelemetryCommandStrings.HeaderStatus);
// Group by traceId to show trace summary
- var traceInfos = new Dictionary();
+ var traceInfos = new Dictionary();
foreach (var resourceSpan in resourceSpans)
{
- var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
+ var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources);
foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
{
@@ -260,28 +272,38 @@ private static void DisplayTracesTable(string json, IInteractionService interact
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);
+ // Track earliest start time across all spans in the trace
+ var earliestStart = info.StartTimeNano.HasValue && span.StartTimeUnixNano.HasValue
+ ? (info.StartTimeNano.Value < span.StartTimeUnixNano.Value ? info.StartTimeNano : span.StartTimeUnixNano)
+ : info.StartTimeNano ?? span.StartTimeUnixNano;
+ traceInfos[traceIdValue] = (info.Resource, info.FirstSpanName, info.TraceId, earliestStart, maxDuration, info.SpanCount + 1, info.HasError || hasError);
}
else
{
- traceInfos[traceIdValue] = (resourceName, duration, 1, hasError);
+ traceInfos[traceIdValue] = (resourceName, span.Name ?? "", traceIdValue, span.StartTimeUnixNano, duration, 1, hasError);
}
}
}
}
- foreach (var (traceIdKey, info) in traceInfos.OrderByDescending(x => x.Value.Duration))
+ foreach (var (_, info) in traceInfos.OrderBy(x => x.Value.StartTimeNano ?? 0))
{
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);
+ var resourceColor = _resourceColorMap.GetColor(info.Resource);
+ var timestamp = info.StartTimeNano.HasValue
+ ? FormatHelpers.FormatConsoleTime(_timeProvider, OtlpHelpers.UnixNanoSecondsToDateTime(info.StartTimeNano.Value))
+ : "";
+ var shortTraceId = OtlpHelpers.ToShortenedId(info.TraceId);
+ var nameMarkup = $"[{resourceColor}]{info.Resource.EscapeMarkup()}[/]: {info.FirstSpanName.EscapeMarkup()} [grey]{shortTraceId}[/]";
+ table.AddRow(timestamp, nameMarkup, info.SpanCount.ToString(CultureInfo.InvariantCulture), durationStr, statusText);
}
- interactionService.DisplayRenderable(table);
- interactionService.DisplayMarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]");
+ _interactionService.DisplayRenderable(table);
+ _interactionService.DisplayMarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]");
}
- private static void DisplayTraceDetails(string json, string traceId)
+ private void DisplayTraceDetails(string json, string traceId, IReadOnlyList allResources)
{
var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse);
var resourceSpans = response?.Data?.ResourceSpans;
@@ -291,7 +313,7 @@ private static void DisplayTraceDetails(string json, string traceId)
foreach (var resourceSpan in resourceSpans ?? [])
{
- var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown";
+ var resourceName = TelemetryCommandHelpers.ResolveResourceName(resourceSpan.Resource, allResources);
foreach (var scopeSpan in resourceSpan.ScopeSpans ?? [])
{
@@ -311,8 +333,8 @@ private static void DisplayTraceDetails(string json, string traceId)
if (spans.Count == 0)
{
- AnsiConsole.MarkupLine($"[bold]Trace: {traceId}[/]");
- AnsiConsole.MarkupLine("[dim]No spans found[/]");
+ _interactionService.DisplayMarkupLine($"[bold]Trace: {traceId}[/]");
+ _interactionService.DisplayMarkupLine("[dim]No spans found[/]");
return;
}
@@ -321,15 +343,15 @@ private static void DisplayTraceDetails(string json, string traceId)
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();
+ _interactionService.DisplayMarkupLine($"[bold]Trace:[/] {traceId}");
+ _interactionService.DisplayMarkupLine($"[bold]Duration:[/] {TelemetryCommandHelpers.FormatDuration(totalDuration)} [bold]Spans:[/] {spans.Count}");
+ _interactionService.DisplayEmptyLine();
// Build tree and display
DisplaySpanTree(spans);
}
- private static void DisplaySpanTree(List spans)
+ private void DisplaySpanTree(List spans)
{
// Build a lookup of children by parent ID
var childrenByParent = spans
@@ -353,7 +375,7 @@ private static void DisplaySpanTree(List spans)
}
}
- private static void DisplaySpanNode(
+ private void DisplaySpanNode(
SpanInfo span,
Dictionary> childrenByParent,
string indent,
@@ -365,9 +387,10 @@ private static void DisplaySpanNode(
{
if (lastResource != null)
{
- AnsiConsole.WriteLine(); // Blank line between resources
+ _interactionService.DisplayEmptyLine(); // Blank line between resources
}
- AnsiConsole.MarkupLine($"{indent}[bold blue]{span.ResourceName.EscapeMarkup()}[/]");
+ var resourceColor = _resourceColorMap.GetColor(span.ResourceName);
+ _interactionService.DisplayMarkupLine($"{indent}[bold {resourceColor}]{span.ResourceName.EscapeMarkup()}[/]");
lastResource = span.ResourceName;
}
@@ -388,7 +411,7 @@ private static void DisplaySpanNode(
? escapedName[..(maxNameLength - 3)] + "..."
: escapedName;
- AnsiConsole.MarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]");
+ _interactionService.DisplayMarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]");
// Render children
if (childrenByParent.TryGetValue(span.SpanId, out var children))
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 80ce4b9bed0..a812cc07b05 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -290,6 +290,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar
builder.Services.AddSingleton();
builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath()));
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddMemoryCache();
// MCP server: aspire.dev docs services.
diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs
index f0eb75f8906..2a1d5ff5f91 100644
--- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs
+++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs
@@ -219,15 +219,15 @@ internal static string SelectAppHostAction {
}
}
- internal static string HeaderTraceId {
+ internal static string HeaderTimestamp {
get {
- return ResourceManager.GetString("HeaderTraceId", resourceCulture);
+ return ResourceManager.GetString("HeaderTimestamp", resourceCulture);
}
}
- internal static string HeaderResource {
+ internal static string HeaderName {
get {
- return ResourceManager.GetString("HeaderResource", resourceCulture);
+ return ResourceManager.GetString("HeaderName", resourceCulture);
}
}
diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx
index 9aedfdb474e..fc786ed33df 100644
--- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx
+++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx
@@ -171,11 +171,11 @@
view telemetry for
-
- Trace Id
+
+ Timestamp
-
- Resource
+
+ Name
Duration
diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf
index 1a1ed4eca5c..b77d2df3fac 100644
--- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf
@@ -37,9 +37,9 @@
Duration
-