File: Commands\TelemetryTracesCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// 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 System.Text.Json.Nodes;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
 
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.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDevLocalhostDashboardApiUrl_UsesLocalhost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var requestedHosts = new List<string>();
        var resourcesJson = JsonSerializer.Serialize(
            new ResourceInfoJson[] { new() { Name = "frontend", InstanceId = null } },
            OtlpJsonSerializerContext.Default.ResourceInfoJsonArray);
 
        var resourceSpans = new OtlpResourceSpansJson[]
        {
            new()
            {
                Resource = TelemetryTestHelper.CreateOtlpResource("frontend", null),
                ScopeSpans =
                [
                    new OtlpScopeSpansJson
                    {
                        Spans =
                        [
                            new OtlpSpanJson
                            {
                                TraceId = "abc1234567890def",
                                SpanId = "span001",
                                Name = "GET /home",
                                StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime),
                                EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime.AddMilliseconds(50)),
                                Status = new OtlpSpanStatusJson { Code = 1 }
                            }
                        ]
                    }
                ]
            }
        };
        var tracesResponse = new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson { ResourceSpans = resourceSpans },
            TotalCount = 1,
            ReturnedCount = 1
        };
        var tracesJson = JsonSerializer.Serialize(tracesResponse, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        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 = "https://nextapp1.dev.localhost:64876",
                ApiToken = "test-token",
                DashboardUrls = ["https://nextapp1.dev.localhost:64876/login?t=test"],
                IsHealthy = true
            }
        };
        monitor.AddConnection("hash1", "socket.hash1", connection);
 
        var handler = new MockHttpMessageHandler(request =>
        {
            requestedHosts.Add(request.RequestUri!.Host);
 
            return request.RequestUri.AbsolutePath switch
            {
                "/api/telemetry/resources" => new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(resourcesJson, System.Text.Encoding.UTF8, "application/json")
                },
                "/api/telemetry/traces" => new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(tracesJson, System.Text.Encoding.UTF8, "application/json")
                },
                _ => new HttpResponseMessage(HttpStatusCode.NotFound)
            };
        });
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --format json -n 5");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.All(requestedHosts, host => Assert.Equal("localhost", host));
 
        var jsonLine = outputWriter.Logs.Single(l => l.TrimStart().StartsWith('['));
        var items = JsonNode.Parse(jsonLine)!.AsArray();
        Assert.Single(items);
 
        var item = items[0]!;
        Assert.Equal("abc1234567890def", item["traceId"]!.GetValue<string>());
 
        var dashboardUrl = item["dashboardUrl"]!.GetValue<string>();
        Assert.Equal("https://nextapp1.dev.localhost:64876/traces/detail/abc1234567890def", dashboardUrl);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDevLocalhostDashboardUrlArg_PreservesDisplayUrl()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var requestedHosts = new List<string>();
 
        var resourceSpans = new OtlpResourceSpansJson[]
        {
            new()
            {
                Resource = TelemetryTestHelper.CreateOtlpResource("frontend", null),
                ScopeSpans =
                [
                    new OtlpScopeSpansJson
                    {
                        Spans =
                        [
                            new OtlpSpanJson
                            {
                                TraceId = "abc1234567890def",
                                SpanId = "span001",
                                Name = "GET /home",
                                StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime),
                                EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime.AddMilliseconds(50)),
                                Status = new OtlpSpanStatusJson { Code = 1 }
                            }
                        ]
                    }
                ]
            }
        };
        var tracesResponse = new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson { ResourceSpans = resourceSpans },
            TotalCount = 1,
            ReturnedCount = 1
        };
        var tracesJson = JsonSerializer.Serialize(tracesResponse, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        var handler = new MockHttpMessageHandler(request =>
        {
            requestedHosts.Add(request.RequestUri!.Host);
 
            return request.RequestUri.AbsolutePath switch
            {
                "/api/telemetry/resources" => new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                },
                "/api/telemetry/traces" => new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(tracesJson, System.Text.Encoding.UTF8, "application/json")
                },
                _ => new HttpResponseMessage(HttpStatusCode.NotFound)
            };
        });
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --format json -n 5 --dashboard-url http://dashboard.dev.localhost:18888");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // HTTP requests should be normalized to localhost
        Assert.All(requestedHosts, host => Assert.Equal("localhost", host));
 
        var jsonLine = outputWriter.Logs.Single(l => l.TrimStart().StartsWith('['));
        var items = JsonNode.Parse(jsonLine)!.AsArray();
        Assert.Single(items);
 
        // The display URL in JSON output should preserve the original *.dev.localhost hostname
        var dashboardUrl = items[0]!["dashboardUrl"]!.GetValue<string>();
        Assert.Equal("http://dashboard.dev.localhost:18888/traces/detail/abc1234567890def", dashboardUrl);
    }
 
    [Theory]
    [InlineData(-1)]
    [InlineData(0)]
    [InlineData(-100)]
    public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"telemetry traces --limit {limitValue}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_TableOutput_ResolvesUniqueResourceNames()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = TelemetryTestHelper.CreateTelemetryTestServices(workspace, outputHelper, outputWriter,
            resources:
            [
                new ResourceInfoJson { Name = "frontend", InstanceId = null },
                new ResourceInfoJson { Name = "backend", InstanceId = null },
            ],
            telemetryEndpoints: new Dictionary<string, string>
            {
                ["/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<RootCommand>();
        var result = command.Parse("otel traces");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.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 (http://localhost:18888/traces/detail/abc1234567890def)", 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 (http://localhost:18888/traces/detail/def9876543210abc)", 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.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using 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<string, string>
            {
                ["/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<RootCommand>();
        var result = command.Parse("otel traces");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.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-55555555: GET /apiservice abc1234 (http://localhost:18888/traces/detail/abc1234567890def)", 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-eeeeeeee: GET /apiservice def9876 (http://localhost:18888/traces/detail/def9876543210abc)", 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);
    }
 
    /// <summary>
    /// Parses table rows from Spectre Console output, splitting by │ borders.
    /// Returns cell values for each data row (excludes header and border rows).
    /// </summary>
    private static string[][] ParseTableDataRows(IReadOnlyList<string> 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 OtlpTelemetryDataJson { ResourceSpans = resourceSpans },
            TotalCount = entries.Length,
            ReturnedCount = entries.Length
        };
 
        return JsonSerializer.Serialize(response, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDashboardUrl_FetchesTracesDirectly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            if (url.Contains("/api/telemetry/traces"))
            {
                var json = BuildTracesJson(("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false));
                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.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --dashboard-url http://localhost:18888");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var dataRows = ParseTableDataRows(outputWriter.Logs);
        Assert.Single(dataRows);
        Assert.Contains("frontend", dataRows[0][1]);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDashboardUrlAndAppHost_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var testInteractionService = new TestInteractionService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --apphost TestAppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        var errorMessage = Assert.Single(testInteractionService.DisplayedErrors);
        Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, errorMessage);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDashboardUrl_401_DisplaysAuthFailedMessage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var testInteractionService = new TestInteractionService();
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            return new HttpResponseMessage(HttpStatusCode.Unauthorized);
        });
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --dashboard-url http://localhost:18888");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.DashboardFailure, exitCode);
        var errorMessage = Assert.Single(testInteractionService.DisplayedErrors);
        Assert.Equal(TelemetryCommandStrings.DashboardAuthFailed, errorMessage);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_JsonOutput_ProducesExpectedJson()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
 
        var resourceSpans = new OtlpResourceSpansJson[]
        {
            new()
            {
                Resource = TelemetryTestHelper.CreateOtlpResource("frontend", null),
                ScopeSpans =
                [
                    new OtlpScopeSpansJson
                    {
                        Spans =
                        [
                            new OtlpSpanJson
                            {
                                TraceId = "abc1234567890def",
                                SpanId = "span0017890abcde",
                                Name = "GET /home",
                                Kind = 2,
                                StartTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime),
                                EndTimeUnixNano = TelemetryTestHelper.DateTimeToUnixNanoseconds(s_testTime.AddMilliseconds(50)),
                                Status = new OtlpSpanStatusJson { Code = 1 },
                                Attributes =
                                [
                                    new OtlpKeyValueJson { Key = "http.method", Value = new OtlpAnyValueJson { StringValue = "GET" } }
                                ]
                            }
                        ]
                    }
                ]
            }
        };
        var apiResponse = new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson { ResourceSpans = resourceSpans },
            TotalCount = 1,
            ReturnedCount = 1
        };
        var responseJson = JsonSerializer.Serialize(apiResponse, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            if (url.Contains("/api/telemetry/traces"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
                };
            }
            return new HttpResponseMessage(HttpStatusCode.NotFound);
        });
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --format json --dashboard-url http://localhost:18888");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLine = outputWriter.Logs.Single(l => l.TrimStart().StartsWith('['));
        var formattedJson = TelemetryTestHelper.FormatJson(jsonLine);
 
        var expected = """
            [
              {
                "traceId": "abc1234567890def",
                "durationMs": 50,
                "title": "GET /home",
                "spans": [
                  {
                    "traceId": "abc1234567890def",
                    "spanId": "span0017890abcde",
                    "kind": "Server",
                    "name": "GET /home",
                    "status": "Ok",
                    "source": "frontend",
                    "durationMs": 50,
                    "timestamp": "1970-01-01T00:00:00Z",
                    "attributes": {
                      "http.method": "GET"
                    }
                  }
                ],
                "hasError": false,
                "timestamp": "1970-01-01T00:00:00Z",
                "dashboardUrl": "http://localhost:18888/traces/detail/abc1234567890def"
              }
            ]
            """;
 
        Assert.Equal(expected, formattedJson, ignoreLineEndingDifferences: true);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_JsonOutput_WithTraceIdAndNoTrace_ReturnsEmptyArray()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
 
        var apiResponse = new TelemetryApiResponse
        {
            Data = new OtlpTelemetryDataJson { ResourceSpans = [] },
            TotalCount = 0,
            ReturnedCount = 0
        };
        var responseJson = JsonSerializer.Serialize(apiResponse, OtlpJsonSerializerContext.Default.TelemetryApiResponse);
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            if (url.Contains("/api/telemetry/traces/abc1234567890def"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
                };
            }
            return new HttpResponseMessage(HttpStatusCode.NotFound);
        });
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --trace-id abc1234567890def --format json --dashboard-url http://localhost:18888");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLine = outputWriter.Logs.Single(l => l.TrimStart().StartsWith('['));
        Assert.Equal("[]", jsonLine);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithSearchOption_PassesSearchToUrl()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        string? capturedUrl = null;
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            if (url.Contains("/api/telemetry/traces"))
            {
                capturedUrl = url;
                var json = BuildTracesJson(("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false));
                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.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --search frontend");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("http://localhost:18888/api/telemetry/traces?search=frontend", capturedUrl);
    }
 
    [Fact]
    public async Task TelemetryTracesCommand_WithDurationSearchFilter_PassesDurationFilterInSearchUrl()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        string? capturedUrl = null;
 
        var handler = new MockHttpMessageHandler(request =>
        {
            var url = request.RequestUri!.ToString();
            if (url.Contains("/api/telemetry/resources"))
            {
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
                };
            }
            if (url.Contains("/api/telemetry/traces"))
            {
                capturedUrl = url;
                var json = BuildTracesJson(("abc1234567890def", "frontend", null, "span001", s_testTime, s_testTime.AddMilliseconds(50), false));
                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.OutputTextWriter = outputWriter;
            options.DisableAnsi = true;
        });
        services.AddSingleton(handler);
        services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("otel traces --dashboard-url http://localhost:18888 --search \"duration:>=50.5\"");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("http://localhost:18888/api/telemetry/traces?search=duration%3A>%3D50.5", capturedUrl);
    }
}