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 - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 2229a93b25d..6132a2e42bf 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index c600cde0a7f..ffdde263163 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 62b7c524918..56bed81db8f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 706662c202c..4cb535a3876 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index dcdcd90906a..6dfbda46862 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 832415dfece..e33e8315c39 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 953b8ad09d7..40e177ca1f7 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 14a09e42154..6f0f0cd665e 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 08243acaaee..2b60de02f03 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 06733344957..e4b0c483738 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index 4f043049a4d..3729489f782 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index a47c1cc3f00..dccc53de57c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -37,9 +37,9 @@ Duration - - Resource - Resource + + Name + Name @@ -52,9 +52,9 @@ Status - - Trace Id - Trace Id + + Timestamp + Timestamp diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 363e6c47721..b4db21d5100 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -281,6 +281,8 @@ + + diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs index c0cd0d1822f..a5a99249a80 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs @@ -72,20 +72,17 @@ public async IAsyncEnumerable WatchSpansAsync( // The HashSet is cleared after draining to prevent unbounded memory growth. var seenSpanIds = new HashSet(); - // Yield existing spans - foreach (var trace in existingTraces.PagedResult.Items) + // Yield existing spans ordered by start time so streaming clients + // receive the initial snapshot in chronological order. + var existingSpans = existingTraces.PagedResult.Items + .SelectMany(trace => trace.Spans) + .Where(span => resourceKey is null || span.Source.ResourceKey.Equals(resourceKey)) + .OrderBy(span => span.StartTime); + + foreach (var span in existingSpans) { - foreach (var span in trace.Spans) - { - // Filter by resource if specified - if (resourceKey is not null && !span.Source.ResourceKey.Equals(resourceKey)) - { - continue; - } - - seenSpanIds.Add(span.SpanId); - yield return span; - } + seenSpanIds.Add(span.SpanId); + yield return span; } // Drain any spans that arrived during the snapshot to ensure we don't miss them diff --git a/src/Aspire.Dashboard/Utils/FormatHelpers.cs b/src/Shared/FormatHelpers.cs similarity index 88% rename from src/Aspire.Dashboard/Utils/FormatHelpers.cs rename to src/Shared/FormatHelpers.cs index ee3c313492d..4139c0558ee 100644 --- a/src/Aspire.Dashboard/Utils/FormatHelpers.cs +++ b/src/Shared/FormatHelpers.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.Text.RegularExpressions; using Aspire.Dashboard.Extensions; -using Aspire.Dashboard.Model; namespace Aspire.Dashboard.Utils; @@ -76,7 +75,16 @@ static string FormatPattern(CultureDetailsKey key, string millisecondFormat) private static MillisecondFormatString GetShortDateLongTimePatternWithMilliseconds(CultureInfo cultureInfo) => GetMillisecondFormatStrings(cultureInfo).ShortDateLongTimePattern; - public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + /// + /// Formats a DateTime as a local time string (HH:mm:ss.fff) for console output. + /// + public static string FormatConsoleTime(TimeProvider timeProvider, DateTime value) + { + return timeProvider.ToLocal(value) + .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + public static string FormatTime(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { cultureInfo ??= CultureInfo.CurrentCulture; var local = timeProvider.ToLocal(value); @@ -91,7 +99,7 @@ public static string FormatTime(BrowserTimeProvider timeProvider, DateTime value }; } - public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + public static string FormatDateTime(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { cultureInfo ??= CultureInfo.CurrentCulture; var local = timeProvider.ToLocal(value); @@ -106,7 +114,7 @@ public static string FormatDateTime(BrowserTimeProvider timeProvider, DateTime v }; } - public static string FormatTimeWithOptionalDate(BrowserTimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) + public static string FormatTimeWithOptionalDate(TimeProvider timeProvider, DateTime value, MillisecondsDisplay millisecondsDisplay = MillisecondsDisplay.None, CultureInfo? cultureInfo = null) { var local = timeProvider.ToLocal(value); diff --git a/src/Shared/Otlp/OtlpHelpers.cs b/src/Shared/Otlp/OtlpHelpers.cs index e9899f87cd0..372df076cf3 100644 --- a/src/Shared/Otlp/OtlpHelpers.cs +++ b/src/Shared/Otlp/OtlpHelpers.cs @@ -86,20 +86,6 @@ public static TimeSpan CalculateDuration(ulong? startNano, ulong? endNano) return TimeSpan.Zero; } - /// - /// Formats a Unix nanosecond timestamp to a time string (HH:mm:ss.fff). - /// - /// Formatted time string or empty string if null. - public static string FormatNanoTimestamp(ulong? nanos) - { - if (nanos.HasValue) - { - return UnixNanoSecondsToDateTime(nanos.Value) - .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); - } - return ""; - } - public static string GetResourceName(IOtlpResource resource, IReadOnlyList allResources) { var count = 0; diff --git a/src/Shared/Otlp/Serialization/OtlpResourceJson.cs b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs index 293a4bc271f..38b8cf7a0ee 100644 --- a/src/Shared/Otlp/Serialization/OtlpResourceJson.cs +++ b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs @@ -49,4 +49,25 @@ public string GetServiceName() return "unknown"; } + + /// + /// Gets the service.instance.id attribute value from the resource. + /// + public string? GetServiceInstanceId() + { + if (Attributes is null) + { + return null; + } + + foreach (var attr in Attributes) + { + if (attr.Key == "service.instance.id" && attr.Value?.StringValue is not null) + { + return attr.Value.StringValue; + } + } + + return null; + } } diff --git a/src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs b/src/Shared/TimeProviderExtensions.cs similarity index 73% rename from src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs rename to src/Shared/TimeProviderExtensions.cs index 38446ba0865..f1108962fb8 100644 --- a/src/Aspire.Dashboard/Extensions/TimeProviderExtensions.cs +++ b/src/Shared/TimeProviderExtensions.cs @@ -1,13 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard.Model; - namespace Aspire.Dashboard.Extensions; internal static class TimeProviderExtensions { - public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) + public static DateTime ToLocal(this TimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) { var dateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTimeOffset.UtcDateTime, timeProvider.LocalTimeZone); dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Local); @@ -15,12 +13,12 @@ public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTimeOf return dateTime; } - public static DateTimeOffset ToLocalDateTimeOffset(this BrowserTimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) + public static DateTimeOffset ToLocalDateTimeOffset(this TimeProvider timeProvider, DateTimeOffset utcDateTimeOffset) { return TimeZoneInfo.ConvertTime(utcDateTimeOffset, timeProvider.LocalTimeZone); } - public static DateTime ToLocal(this BrowserTimeProvider timeProvider, DateTime dateTime) + public static DateTime ToLocal(this TimeProvider timeProvider, DateTime dateTime) { if (dateTime.Kind == DateTimeKind.Local) { diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs index 0469d945489..df93121e021 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; using Aspire.Cli.Tests.Utils; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -27,105 +29,6 @@ public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand() Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); } - [Fact] - public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel logs"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel spans"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Fact] - public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("otel traces"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetryLogsCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry logs --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetrySpansCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry spans --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(-100)] - public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse($"telemetry traces --limit {limitValue}"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); - } - [Fact] public void BuildResourceQueryString_WithNoResources_ReturnsEmptyString() { @@ -190,11 +93,11 @@ public void ToShortenedId_WithShortId_ReturnsOriginal() } [Fact] - public void FormatNanoTimestamp_WithValidTimestamp_ReturnsFormattedTime() + public void FormatConsoleTime_WithValidTimestamp_ReturnsFormattedTime() { // 2026-01-31 12:00:00.123 UTC - var nanoTimestamp = 1769860800123000000UL; - var result = OtlpHelpers.FormatNanoTimestamp(nanoTimestamp); + var dateTime = OtlpHelpers.UnixNanoSecondsToDateTime(1769860800123000000UL); + var result = FormatHelpers.FormatConsoleTime(TimeProvider.System, dateTime); // Result should contain time component (HH:mm:ss.fff) Assert.Matches(@"\d{2}:\d{2}:\d{2}\.\d{3}", result); @@ -240,4 +143,72 @@ public void FormatTraceLink_WithNullDashboardUrl_ReturnsPlainText() Assert.DoesNotContain("[link=", result); Assert.Equal("abc1234", result); // Just the shortened ID } + + [Fact] + public void ToOtlpResources_ConvertsResourceInfoJsonToOtlpResources() + { + var resources = new ResourceInfoJson[] + { + new() { Name = "frontend", InstanceId = "abc123" }, + new() { Name = "backend", InstanceId = null }, + new() { Name = "frontend", InstanceId = "xyz789" }, + }; + + var result = TelemetryCommandHelpers.ToOtlpResources(resources); + + Assert.Equal(3, result.Count); + Assert.Equal("frontend", result[0].ResourceName); + Assert.Equal("abc123", result[0].InstanceId); + Assert.Equal("backend", result[1].ResourceName); + Assert.Null(result[1].InstanceId); + Assert.Equal("frontend", result[2].ResourceName); + Assert.Equal("xyz789", result[2].InstanceId); + + // Empty input yields empty output + Assert.Empty(TelemetryCommandHelpers.ToOtlpResources([])); + } + + [Theory] + [MemberData(nameof(ResolveResourceNameTestData))] + internal void ResolveResourceName_ResolvesExpectedName( + OtlpResourceJson? resource, + IOtlpResource[] allResources, + string expectedName) + { + var result = TelemetryCommandHelpers.ResolveResourceName(resource, allResources); + + Assert.Equal(expectedName, result); + } + + public static IEnumerable ResolveResourceNameTestData() + { + var guid = Guid.Parse("aabbccdd-1122-3344-5566-778899001122"); + var guidStr = guid.ToString(); + + // null resource → "unknown" + yield return [null, Array.Empty(), "unknown"]; + // no attributes → "unknown" + yield return [new OtlpResourceJson { Attributes = null }, new IOtlpResource[] { new SimpleOtlpResource("unknown", null) }, "unknown"]; + // unique service name → bare name + yield return [MakeResource("frontend", "abc123"), new IOtlpResource[] { new SimpleOtlpResource("frontend", "abc123") }, "frontend"]; + // missing instance id, single resource → bare name + yield return [MakeResource("apiservice", null), new IOtlpResource[] { new SimpleOtlpResource("apiservice", null) }, "apiservice"]; + // replicas with non-GUID instance id → name-instanceId + yield return [MakeResource("frontend", "abc123"), new IOtlpResource[] { new SimpleOtlpResource("frontend", "abc123"), new SimpleOtlpResource("frontend", "xyz789") }, "frontend-abc123"]; + // replicas with GUID instance id → name-shortened8chars + yield return [MakeResource("worker", guidStr), new IOtlpResource[] { new SimpleOtlpResource("worker", guidStr), new SimpleOtlpResource("worker", Guid.NewGuid().ToString()) }, $"worker-{guid:N}"[..15]]; + } + + private static OtlpResourceJson MakeResource(string serviceName, string? instanceId) + { + var attrs = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } }, + }; + if (instanceId is not null) + { + attrs.Add(new() { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = instanceId } }); + } + return new OtlpResourceJson { Attributes = [.. attrs] }; + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs new file mode 100644 index 00000000000..3501a2edc44 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryLogsCommandTests.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetryLogsCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryLogsCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry logs --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetryLogsCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "redis", InstanceId = null }, + new ResourceInfoJson { Name = "apiservice", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson( + ("redis", null, 9, "Information", "Ready to accept connections", s_testTime), + ("apiservice", null, 9, "Information", "Request received", s_testTime.AddSeconds(1))) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // With ANSI disabled, output is plain text: "timestamp severity resourceName body" + var logLines = outputWriter.Logs.Where(l => l.Contains("redis") || l.Contains("apiservice")).ToList(); + Assert.Equal(2, logLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} INFO redis Ready to accept connections", logLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddSeconds(1))} INFO apiservice Request received", logLines[1]); + } + + [Fact] + public async Task TelemetryLogsCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/logs"] = BuildLogsJson( + ("apiservice", guid1.ToString(), 9, "Information", "Hello from replica 1", s_testTime), + ("apiservice", guid2.ToString(), 13, "Warning", "Slow response from replica 2", s_testTime.AddSeconds(1))) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Replicas get shortened GUID appended: apiservice-11111111 and apiservice-aaaaaaaa + var logLines = outputWriter.Logs.Where(l => l.Contains("apiservice")).ToList(); + Assert.Equal(2, logLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} INFO apiservice-11111111 Hello from replica 1", logLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddSeconds(1))} WARN apiservice-aaaaaaaa Slow response from replica 2", logLines[1]); + } + + private static string BuildLogsJson(params (string serviceName, string? instanceId, int severityNumber, string severityText, string body, DateTime time)[] entries) + { + var resourceLogs = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceLogsJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeLogs = + [ + new OtlpScopeLogsJson + { + LogRecords = g.Select(e => new OtlpLogRecordJson + { + TimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.time), + SeverityNumber = e.severityNumber, + SeverityText = e.severityText, + Body = new OtlpAnyValueJson { StringValue = e.body } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = resourceLogs }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs new file mode 100644 index 00000000000..ac5326b90d6 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetrySpansCommandTests.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetrySpansCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetrySpansCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry spans --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetrySpansCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "frontend", InstanceId = null }, + new ResourceInfoJson { Name = "backend", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/spans"] = BuildSpansJson( + ("frontend", null, "span001", "GET /index", s_testTime, s_testTime.AddMilliseconds(50), false), + ("backend", null, "span002", "SELECT * FROM users", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(30), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Span output format: "timestamp STATUS shortSpanId resourceName duration spanName" + var spanLines = outputWriter.Logs.Where(l => l.Contains("frontend") || l.Contains("backend")).ToList(); + Assert.Equal(2, spanLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} OK 50ms frontend: GET /index span001", spanLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10))} ERR 20ms backend: SELECT * FROM users span002", spanLines[1]); + } + + [Fact] + public async Task TelemetrySpansCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/spans"] = BuildSpansJson( + ("apiservice", guid1.ToString(), "span001", "GET /api/products", s_testTime, s_testTime.AddMilliseconds(75), false), + ("apiservice", guid2.ToString(), "span002", "POST /api/orders", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(60), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Replicas get shortened GUID appended + var spanLines = outputWriter.Logs.Where(l => l.Contains("apiservice")).ToList(); + Assert.Equal(2, spanLines.Count); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime)} OK 75ms apiservice-11111111: GET /api/products span001", spanLines[0]); + Assert.Equal($"{FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10))} ERR 50ms apiservice-aaaaaaaa: POST /api/orders span002", spanLines[1]); + } + + private static string BuildSpansJson(params (string serviceName, string? instanceId, string spanId, string name, DateTime startTime, DateTime endTime, bool hasError)[] entries) + { + var resourceSpans = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceSpansJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Spans = g.Select(e => new OtlpSpanJson + { + SpanId = e.spanId, + TraceId = "trace001", + Name = e.name, + StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.startTime), + EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.endTime), + Status = e.hasError ? new OtlpSpanStatusJson { Code = 2 } : new OtlpSpanStatusJson { Code = 1 } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = resourceSpans }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs new file mode 100644 index 00000000000..5adeabac965 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTestHelper.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aspire.Cli.Tests.Commands; + +internal static class TelemetryTestHelper +{ + /// + /// A fixed base time used by telemetry tests. All test timestamps should be expressed + /// as offsets from this value (e.g. s_testTime.AddMilliseconds(50)). + /// + internal static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Converts a to Unix nanoseconds (nanoseconds since the Unix epoch). + /// + internal static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var timeSinceEpoch = dateTime.ToUniversalTime() - unixEpoch; + + return (ulong)timeSinceEpoch.Ticks * 100; + } + + /// + /// Creates an with the specified service name and optional instance ID. + /// + internal static OtlpResourceJson CreateOtlpResource(string serviceName, string? instanceId) + { + var attrs = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } }, + }; + if (instanceId is not null) + { + attrs.Add(new() { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = instanceId } }); + } + return new OtlpResourceJson { Attributes = [.. attrs] }; + } + + /// + /// Creates a fully configured for telemetry command tests, + /// with a mock backchannel and HTTP handler that serves resource and telemetry data. + /// + /// The temporary workspace for the test. + /// The xUnit test output helper. + /// The test output writer to capture console output. + /// The resource list returned by the /api/telemetry/resources endpoint. + /// + /// A dictionary mapping URL substrings (e.g. "/api/telemetry/logs") to their JSON response content. + /// + internal static ServiceProvider CreateTelemetryTestServices( + TemporaryWorkspace workspace, + ITestOutputHelper outputHelper, + TestOutputTextWriter outputWriter, + ResourceInfoJson[] resources, + Dictionary telemetryEndpoints) + { + var resourcesJson = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = "http://localhost:18888", + ApiToken = "test-token", + DashboardUrls = ["http://localhost:18888/login?t=test"], + IsHealthy = true + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var handler = new MockHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("/api/telemetry/resources")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesJson, System.Text.Encoding.UTF8, "application/json") + }; + } + + foreach (var (urlPattern, json) in telemetryEndpoints) + { + if (url.Contains(urlPattern)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + options.DisableAnsi = true; + }); + + // Register the handler as a singleton so it is disposed with the ServiceProvider, + // rather than at the end of this method (which would cause ObjectDisposedException). + services.AddSingleton(handler); + services.Replace(ServiceDescriptor.Singleton(new MockHttpClientFactory(handler))); + + return services.BuildServiceProvider(); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs new file mode 100644 index 00000000000..7e2e3d52e9e --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryTracesCommandTests.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetryTracesCommandTests(ITestOutputHelper outputHelper) +{ + private static readonly DateTime s_testTime = TelemetryTestHelper.s_testTime; + [Fact] + public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry traces --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetryTracesCommand_TableOutput_ResolvesUniqueResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "frontend", InstanceId = null }, + new ResourceInfoJson { Name = "backend", InstanceId = null }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/traces"] = BuildTracesJson( + ("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false), + ("def9876543210abc", "backend", null, "span002", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(30), false)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Parse table data rows for assertion + var dataRows = ParseTableDataRows(outputWriter.Logs); + + // Traces are sorted oldest first: trace1 (s_testTime) first, trace2 (s_testTime+10ms) second + Assert.Equal(2, dataRows.Length); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime), dataRows[0][0]); + Assert.Equal("frontend: GET /frontend abc1234", dataRows[0][1]); + Assert.Equal("1", dataRows[0][2]); + Assert.Equal("50ms", dataRows[0][3]); + Assert.Equal("OK", dataRows[0][4]); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10)), dataRows[1][0]); + Assert.Equal("backend: GET /backend def9876", dataRows[1][1]); + Assert.Equal("1", dataRows[1][2]); + Assert.Equal("20ms", dataRows[1][3]); + Assert.Equal("OK", dataRows[1][4]); + + // Check summary line + var summaryLine = outputWriter.Logs.Last(l => l.StartsWith("Showing", StringComparison.Ordinal)); + Assert.Equal("Showing 2 of 2 traces", summaryLine); + } + + [Fact] + public async Task TelemetryTracesCommand_TableOutput_ResolvesReplicaResourceNames() + { + var guid1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); + var guid2 = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter, + resources: + [ + new ResourceInfoJson { Name = "apiservice", InstanceId = guid1.ToString() }, + new ResourceInfoJson { Name = "apiservice", InstanceId = guid2.ToString() }, + ], + telemetryEndpoints: new Dictionary + { + ["/api/telemetry/traces"] = BuildTracesJson( + ("abc1234567890def", "apiservice", guid1.ToString(), "span001", s_testTime, s_testTime.AddMilliseconds(75), false), + ("def9876543210abc", "apiservice", guid2.ToString(), "span002", s_testTime.AddMilliseconds(10), s_testTime.AddMilliseconds(60), true)) + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("otel traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Parse table data rows for assertion + var dataRows = ParseTableDataRows(outputWriter.Logs); + + // Traces sorted oldest first: trace1 (s_testTime) first, trace2 (s_testTime+10ms) second + Assert.Equal(2, dataRows.Length); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime), dataRows[0][0]); + Assert.Equal("apiservice-11111111: GET /apiservice abc1234", dataRows[0][1]); + Assert.Equal("1", dataRows[0][2]); + Assert.Equal("75ms", dataRows[0][3]); + Assert.Equal("OK", dataRows[0][4]); + + Assert.Equal(FormatHelpers.FormatConsoleTime(TimeProvider.System, s_testTime.AddMilliseconds(10)), dataRows[1][0]); + Assert.Equal("apiservice-aaaaaaaa: GET /apiservice def9876", dataRows[1][1]); + Assert.Equal("1", dataRows[1][2]); + Assert.Equal("50ms", dataRows[1][3]); + Assert.Equal("ERR", dataRows[1][4]); + + // Check summary line + var summaryLine = outputWriter.Logs.Last(l => l.StartsWith("Showing", StringComparison.Ordinal)); + Assert.Equal("Showing 2 of 2 traces", summaryLine); + } + + /// + /// Parses table rows from Spectre Console output, splitting by │ borders. + /// Returns cell values for each data row (excludes header and border rows). + /// + private static string[][] ParseTableDataRows(IReadOnlyList outputLines) + { + // Find rows with │ separator, then skip the first one which is the header row. + // Border rows contain ─ characters and are also filtered out. + return outputLines + .Where(line => line.Contains('│') && !line.Contains('─') && !line.Contains('┬') && !line.Contains('┼') && !line.Contains('┴')) + .Skip(1) // Skip header row + .Select(line => line.Split('│', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .Where(cells => cells.Length >= 5) + .ToArray(); + } + + private static string BuildTracesJson(params (string traceId, string serviceName, string? instanceId, string spanId, DateTime startTime, DateTime endTime, bool hasError)[] entries) + { + var resourceSpans = entries + .GroupBy(e => (e.serviceName, e.instanceId)) + .Select(g => new OtlpResourceSpansJson + { + Resource = TelemetryTestHelper.CreateOtlpResource(g.Key.serviceName, g.Key.instanceId), + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Spans = g.Select(e => new OtlpSpanJson + { + TraceId = e.traceId, + SpanId = e.spanId, + Name = $"GET /{e.serviceName}", + StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.startTime), + EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(e.endTime), + Status = e.hasError ? new OtlpSpanStatusJson { Code = 2 } : new OtlpSpanStatusJson { Code = 1 } + }).ToArray() + } + ] + }).ToArray(); + + var response = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = resourceSpans }, + TotalCount = entries.Length, + ReturnedCount = entries.Length + }; + + return JsonSerializer.Serialize(response, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 305d4ff4781..32ef1a8de78 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -190,6 +190,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 4e616956ff1..cac52331be3 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -593,6 +593,96 @@ public async Task WatchLogsAsync_CanBeCancelled() Assert.True(watchTask.IsCompleted); } + [Fact] + public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() + { + // Arrange + var repository = CreateRepository(); + + // Add spans with non-chronological start times across different traces + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with latest start time added first + CreateSpan(traceId: "trace1", spanId: "span-late", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(11)) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with earliest start time added second + CreateSpan(traceId: "trace2", spanId: "span-early", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(2)) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + // Span with middle start time added last + CreateSpan(traceId: "trace3", spanId: "span-mid", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(6)) + } + } + } + } + }); + + const int expectedSpans = 3; + + using var cts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(); + using var doneCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, doneCts.Token); + var receivedSpans = new List(); + + // Act + try + { + await foreach (var span in repository.WatchSpansAsync(resourceKey: null, linkedCts.Token)) + { + receivedSpans.Add(span); + if (receivedSpans.Count == expectedSpans) + { + doneCts.Cancel(); + } + } + } + catch (OperationCanceledException) + { + // Expected when all spans received + } + + // Assert - spans should be ordered by start time regardless of insertion order + Assert.Equal(expectedSpans, receivedSpans.Count); + Assert.Equal("Test span. Id: span-early", receivedSpans[0].Name); + Assert.Equal("Test span. Id: span-mid", receivedSpans[1].Name); + Assert.Equal("Test span. Id: span-late", receivedSpans[2].Name); + } + [Fact] public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() {