File: CliTelemetryTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end test that verifies telemetry enrichment tags are present on spans
/// exported via the profiling capture pipeline. Uses <c>aspire start --capture-profile</c>
/// which activates the profiling TracerProvider, creates CLI profiling spans enriched by
/// CliTagEnrichmentProcessor, and exports them to a
/// profile archive file that we can inspect.
/// </summary>
public sealed class CliTelemetryTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task OtlpExportedSpansContainEnrichmentTags()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
 
        var workspace = TemporaryWorkspace.Create(output);
 
        // Docker socket needed because aspire start runs an AppHost that may launch containers
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        // Create a project so aspire start has an AppHost to launch
        await auto.AspireNewAsync("TelemetryTestApp", counter);
 
        // Navigate to the AppHost
        await auto.TypeAsync("cd TelemetryTestApp/TelemetryTestApp.AppHost");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Start the AppHost with --capture-profile to generate CLI profiling spans.
        // --capture-profile activates the profiling TracerProvider which creates spans on
        // the Aspire.Cli.Profiling activity source. The CliTagEnrichmentProcessor enriches
        // these spans with default tags (aspire.cli.version, etc.) before export.
        // The profile is exported to a ZIP archive containing OTLP-format trace data.
        // Write diagnostic files into the .aspire-diagnostics/ directory so TerminalRun
        // always copies them to testresults (not just on failure).
        //
        // NOTE: --capture-profile is a "run, capture, stop" workflow — the CLI starts the
        // AppHost, waits for readiness, stops it, collects traces from the profile dashboard,
        // and exits. The AppHost (and its dashboard) is already shut down by the time the
        // command returns, so we skip the dashboard health check.
        var diagDir = $"$ASPIRE_E2E_WORKSPACE/{CliE2EAutomatorHelpers.DiagnosticsDirectoryName}";
        var profilePath = $"{diagDir}/profile.zip";
        var verResultPath = $"{diagDir}/ver_result";
        await auto.RunCommandAsync($"mkdir -p {diagDir}", counter);
        await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(4), additionalArgs: $"--capture-profile --capture-profile-output {profilePath}", skipDashboardCheck: true);
 
        // Dump profile contents for debugging visibility in the recording
        await auto.TypeAsync($"unzip -l {profilePath}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Extract the trace data from the profile archive and check for enrichment tags.
        // The profile ZIP contains traces/profile.json in OTLP JSON format:
        //   { "resourceSpans": [{ "scopeSpans": [{ "spans": [{ "attributes": [{ "key": "...", "value": {...} }] }] }] }] }
        // Some spans may have no attributes field (omitted by WhenWritingNull), so use (.attributes // [])
        // to avoid jq errors on null iteration.
        await auto.TypeAsync($"unzip -p {profilePath} traces/profile.json | jq -e '[.resourceSpans[].scopeSpans[].spans[] | (.attributes // [])[] | select(.key == \"aspire.cli.version\")] | length > 0' >/dev/null 2>&1 && echo PASS > {verResultPath} || echo FAIL > {verResultPath}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Dump some span data for debugging
        await auto.TypeAsync($"unzip -p {profilePath} traces/profile.json | jq '.resourceSpans[].scopeSpans[].spans[] | {{name, attributes: [(.attributes // [])[] | select(.key | startswith(\"aspire.cli\"))]}}' | head -50");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Clear and check the enrichment tag result
        await auto.TypeAsync("clear");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.TypeAsync($"cat {verResultPath}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("PASS", timeout: TimeSpan.FromSeconds(5));
        await auto.WaitForSuccessPromptAsync(counter);
    }
}