File: Telemetry\AspireCliTelemetryTests.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.Diagnostics;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Telemetry;
 
public class AspireCliTelemetryTests
{
    [Fact]
    public void StartReportedActivity_CreatesActivityWithCorrectName()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartReportedActivity("test-activity", ActivityKind.Internal);
 
        Assert.NotNull(activity);
        Assert.Equal("test-activity", activity.OperationName);
        Assert.Equal(ActivityKind.Internal, activity.Kind);
    }
 
    [Fact]
    public void StartReportedActivity_WithParentContext_CreatesChildActivity()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
        var parentContext = ActivityContext.Parse("00-0102030405060708090a0b0c0d0e0f10-1112131415161718-01", null);
 
        using var activity = fixture.Telemetry.StartReportedActivity("test-activity", ActivityKind.Internal, parentContext);
 
        Assert.NotNull(activity);
        Assert.Equal(parentContext.TraceId, activity.TraceId);
        Assert.Equal(parentContext.SpanId, activity.ParentSpanId);
    }
 
    [Fact]
    public async Task StartDiagnosticActivity_CreatesActivityWithCorrectNameAndDefaultTags()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartDiagnosticActivity("test-diagnostic");
 
        Assert.NotNull(activity);
        Assert.Equal("test-diagnostic", activity.OperationName);
 
        // Verify all default tags are included
        var defaultTags = await fixture.Telemetry.GetDefaultTagsAsync();
        var activityTags = activity.TagObjects.ToDictionary(t => t.Key, t => t.Value);
        foreach (var tag in defaultTags)
        {
            Assert.True(activityTags.ContainsKey(tag.Key), $"Activity is missing tag '{tag.Key}'");
            Assert.Equal(tag.Value, activityTags[tag.Key]);
        }
    }
 
    [Fact]
    public void StartDiagnosticActivity_WithKind_CreatesActivityWithCorrectKind()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartDiagnosticActivity("test-client", ActivityKind.Client);
 
        Assert.NotNull(activity);
        Assert.Equal("test-client", activity.OperationName);
        Assert.Equal(ActivityKind.Client, activity.Kind);
    }
 
    [Fact]
    public void StartDiagnosticActivity_WithParentContext_CreatesChildActivity()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
        var parentContext = ActivityContext.Parse("00-1112131415161718191a1b1c1d1e1f20-2122232425262728-01", null);
 
        using var activity = fixture.Telemetry.StartDiagnosticActivity("test-activity", ActivityKind.Internal, parentContext);
 
        Assert.NotNull(activity);
        Assert.Equal(parentContext.TraceId, activity.TraceId);
        Assert.Equal(parentContext.SpanId, activity.ParentSpanId);
    }
 
    [Fact]
    public void StartDiagnosticActivity_UsesCallerMemberName_WhenNoNameProvided()
    {
        using var fixture = new TelemetryFixture(sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartDiagnosticActivity();
 
        Assert.NotNull(activity);
        Assert.Equal(nameof(StartDiagnosticActivity_UsesCallerMemberName_WhenNoNameProvided), activity.OperationName);
    }
 
    [Fact]
    public void RecordError_LogsError()
    {
        var logger = new FakeLogger<AspireCliTelemetry>();
        using var fixture = new TelemetryFixture(logger: logger);
        var exception = new InvalidOperationException("Test exception");
 
        fixture.Telemetry.RecordError("Error occurred", exception);
 
        var logRecord = Assert.Single(logger.Collector.GetSnapshot());
        Assert.Equal(LogLevel.Error, logRecord.Level);
        Assert.Equal("Error occurred", logRecord.Message);
        Assert.Same(exception, logRecord.Exception);
    }
 
    [Fact]
    public async Task RecordError_AddsActivityEventWithDefaultTags_WhenReportedActivityIsActive()
    {
        using var fixture = new TelemetryFixture();
        var exception = new InvalidOperationException("Test exception");
 
        using var activity = fixture.Telemetry.StartReportedActivity("test-activity", ActivityKind.Internal);
        Assert.NotNull(activity);
 
        fixture.Telemetry.RecordError("Error occurred", exception);
 
        var events = activity.Events.ToList();
        var exceptionEvent = Assert.Single(events);
        Assert.Equal(TelemetryConstants.Events.Error, exceptionEvent.Name);
 
        var eventTags = exceptionEvent.Tags.ToDictionary(t => t.Key, t => t.Value);
        Assert.Equal(typeof(InvalidOperationException).FullName, eventTags[TelemetryConstants.Tags.ExceptionType]);
        Assert.Equal("Test exception", eventTags[TelemetryConstants.Tags.ExceptionMessage]);
        // Note: exception.stacktrace may not be present if the exception was never thrown
 
        // RecordError adds default tags directly to the error event at creation time
        // so they are available even if the enrichment processor has not run yet.
        var defaultTags = await fixture.Telemetry.GetDefaultTagsAsync();
        Assert.NotEmpty(defaultTags);
        foreach (var tag in defaultTags)
        {
            Assert.True(eventTags.ContainsKey(tag.Key), $"Error event is missing default tag '{tag.Key}'");
            Assert.Equal(tag.Value?.ToString(), eventTags[tag.Key]?.ToString());
        }
    }
 
    [Fact]
    public void RecordError_DoesNotThrow_WhenNoActivityIsActive()
    {
        var logger = new FakeLogger<AspireCliTelemetry>();
        using var fixture = new TelemetryFixture(logger: logger);
        var exception = new InvalidOperationException("Test exception");
 
        // Should not throw even when there's no active activity
        fixture.Telemetry.RecordError("Error occurred", exception);
 
        // Verify logging still happens
        var logRecord = Assert.Single(logger.Collector.GetSnapshot());
        Assert.Equal(LogLevel.Error, logRecord.Level);
    }
 
    [Fact]
    public void RecordError_FindsReportedActivity_InHierarchy()
    {
        using var fixture = new TelemetryFixture();
        var otherSourceName = $"Test.{Path.GetRandomFileName()}";
 
        using var otherListener = new ActivityListener
        {
            ShouldListenTo = source => source.Name == otherSourceName,
            Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded
        };
        ActivitySource.AddActivityListener(otherListener);
 
        var exception = new InvalidOperationException("Test exception");
 
        // Start a reported activity (parent)
        using var reportedActivity = fixture.Telemetry.StartReportedActivity("parent-activity", ActivityKind.Internal);
        Assert.NotNull(reportedActivity);
 
        // Start a child activity from a different source
        using var otherSource = new ActivitySource(otherSourceName);
        using var childActivity = otherSource.StartActivity("child-activity");
        Assert.NotNull(childActivity);
 
        // RecordError should find the reported activity in the hierarchy
        fixture.Telemetry.RecordError("Error in child", exception);
 
        // The error should be recorded on the reported activity, not the child
        var events = reportedActivity.Events.ToList();
        Assert.Single(events);
 
        // Child activity should not have the error event
        Assert.Empty(childActivity.Events);
    }
 
    [Fact]
    public void RecordError_DoesNotRecordEvent_WhenOnlyDiagnosticActivityIsActive()
    {
        var logger = new FakeLogger<AspireCliTelemetry>();
        using var fixture = new TelemetryFixture(logger: logger);
        var exception = new InvalidOperationException("Test exception");
 
        using var activity = fixture.Telemetry.StartDiagnosticActivity("test-activity");
        Assert.NotNull(activity);
 
        fixture.Telemetry.RecordError("Error occurred", exception);
 
        // FindKnownActivity only looks for ReportedActivitySource, so no event should be added
        Assert.Empty(activity.Events);
        Assert.Equal(ActivityStatusCode.Unset, activity.Status);
 
        // But logging should still happen
        var logRecord = Assert.Single(logger.Collector.GetSnapshot());
        Assert.Equal(LogLevel.Error, logRecord.Level);
    }
 
    [Fact]
    public async Task Initialize_AddsMachineInformationTags()
    {
        var machineInfoProvider = new TelemetryFixture.TestMachineInformationProvider
        {
            DeviceId = "test-device-id",
            MacAddressHash = "test-mac-hash"
        };
        using var fixture = new TelemetryFixture(machineInfoProvider);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Contains(tags, t => t.Key == "machine.device_id" && (string?)t.Value == "test-device-id");
        Assert.Contains(tags, t => t.Key == "machine.mac_address_hash" && (string?)t.Value == "test-mac-hash");
    }
 
    [Fact]
    public async Task Initialize_AddsOsInformationTags()
    {
        using var fixture = new TelemetryFixture();
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        var expectedOsName = AspireCliTelemetry.GetOsName();
        var expectedOsType = AspireCliTelemetry.GetOsType();
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.OsName && (string?)t.Value == expectedOsName);
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.OsVersion && t.Value is string s && s == Environment.OSVersion.Version.ToString());
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.OsType && (string?)t.Value == expectedOsType);
    }
 
    [Fact]
    public void Initialize_AddsCodingAgentTag_WhenCodingAgentIsDetected()
    {
        var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector
        {
            CodingAgent = "copilot"
        };
        using var fixture = new TelemetryFixture(codingAgentDetector: codingAgentDetector, sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartReportedActivity(TelemetryConstants.Activities.Main);
 
        Assert.NotNull(activity);
        Assert.Equal("copilot", activity.GetTagItem(TelemetryConstants.Tags.CodingAgent));
    }
 
    [Fact]
    public async Task Initialize_DoesNotAddCodingAgentTag_WhenCodingAgentIsNotDetected()
    {
        using var fixture = new TelemetryFixture();
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.DoesNotContain(tags, t => t.Key == TelemetryConstants.Tags.CodingAgent);
    }
 
    [Fact]
    public async Task Initialize_AddsInternalMicrosoftTag()
    {
        Assert.Equal("aspire.cli.microsoft_internal_source", TelemetryConstants.Tags.InternalMicrosoftSource);
        Assert.Equal("aspire.cli.microsoft_internal_alias", TelemetryConstants.Tags.InternalMicrosoftAlias);
        Assert.Equal("aspire.cli.microsoft_internal_domain", TelemetryConstants.Tags.InternalMicrosoftDomain);
        Assert.Equal("aspire.cli.microsoft_internal_detector.outcome", TelemetryConstants.Tags.InternalMicrosoftDetectorOutcome);
        Assert.Equal("aspire.cli.microsoft_internal_probe.failure_code", TelemetryConstants.Tags.InternalMicrosoftProbeFailureCode);
        Assert.Equal("aspire.cli.microsoft_internal_probe.failure_stage", TelemetryConstants.Tags.InternalMicrosoftProbeFailureStage);
        Assert.Equal("aspire.cli.microsoft_internal_probe.exception_type", TelemetryConstants.Tags.InternalMicrosoftProbeExceptionType);
        Assert.Equal("aspire.cli.microsoft_internal_probe.process_exit_code", TelemetryConstants.Tags.InternalMicrosoftProbeProcessExitCode);
        Assert.Equal("aspire.cli.microsoft_internal_probe.http_status_code", TelemetryConstants.Tags.InternalMicrosoftProbeHttpStatusCode);
 
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = true,
            Source = "test source",
            Alias = "test.alias",
            Domain = "TEST"
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoft && t.Value is true);
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoftSource && (string?)t.Value == "test source");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoftAlias && (string?)t.Value == "test.alias");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoftDomain && (string?)t.Value == "TEST");
    }
 
    [Fact]
    public async Task Initialize_AddsExplicitFalseInternalMicrosoftTag_WhenNotDetected()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = false
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Collection(
            GetInternalMicrosoftTags(tags),
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoft, tag.Key);
                Assert.False((bool?)tag.Value);
            });
    }
 
    [Fact]
    public async Task Initialize_DoesNotAddOptionalInternalMicrosoftTags_WhenValuesAreNotDetected()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = true,
            Source = "test source"
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Collection(
            GetInternalMicrosoftTags(tags),
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoft, tag.Key);
                Assert.True((bool?)tag.Value);
            },
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoftSource, tag.Key);
                Assert.Equal("test source", tag.Value);
            });
    }
 
    [Fact]
    public async Task Initialize_SuppressesAliasAndDomainInCI()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = true,
            Source = "test source",
            Alias = "test.alias",
            Domain = "TEST"
        };
        var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector
        {
            IsCIEnvironmentResult = true
        };
        using var fixture = new TelemetryFixture(ciEnvironmentDetector: ciDetector, internalMicrosoftDetector: internalMicrosoftDetector);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoft && t.Value is true);
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.InternalMicrosoftSource && (string?)t.Value == "test source");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.DeploymentEnvironmentName && (string?)t.Value == "ci");
        Assert.Collection(
            GetInternalMicrosoftTags(tags),
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoft, tag.Key);
                Assert.True((bool?)tag.Value);
            },
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoftSource, tag.Key);
                Assert.Equal("test source", tag.Value);
            });
    }
 
    [Fact]
    public async Task Initialize_DoesNotRunInternalMicrosoftDetectorWhenReportedTelemetryIsDisabled()
    {
        var provider = new TelemetryFixture.TestMachineInformationProvider();
        var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector();
        var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector();
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = true
        };
        var tagsSource = new TelemetryTagsSource(NullLogger<TelemetryTagsSource>.Instance);
        var telemetry = new AspireCliTelemetry(
            NullLogger<AspireCliTelemetry>.Instance,
            provider,
            ciDetector,
            codingAgentDetector,
            internalMicrosoftDetector,
            new TelemetryConfiguration { ReportedTelemetryEnabled = false },
            AspireCliTelemetry.ReportedActivitySourceName,
            AspireCliTelemetry.DiagnosticsActivitySourceName,
            Utils.TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(AppContext.BaseDirectory)),
            tagsSource);
 
        telemetry.Initialize();
        await tagsSource.TagsTask;
 
        Assert.Equal(0, internalMicrosoftDetector.InvocationCount);
        Assert.Empty(GetInternalMicrosoftTags(await telemetry.GetDefaultTagsAsync()));
    }
 
    [Fact]
    public async Task CompleteInternalMicrosoftDiagnosticsAsync_DoesNotWaitForTagsWhenReportedTelemetryIsDisabled()
    {
        var blockedTag = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
        var machineInformationProvider = new TelemetryFixture.TestMachineInformationProvider
        {
            GetDeviceIdCallback = () => blockedTag.Task
        };
        var tagsSource = new TelemetryTagsSource(NullLogger<TelemetryTagsSource>.Instance);
        var telemetry = new AspireCliTelemetry(
            NullLogger<AspireCliTelemetry>.Instance,
            machineInformationProvider,
            new TelemetryFixture.TestCIEnvironmentDetector(),
            new TelemetryFixture.TestCodingAgentDetector(),
            new TelemetryFixture.TestInternalMicrosoftDetector(),
            new TelemetryConfiguration
            {
                ReportedTelemetryEnabled = false,
                EmitInternalMicrosoftDiagnostics = true
            },
            AspireCliTelemetry.ReportedActivitySourceName,
            AspireCliTelemetry.DiagnosticsActivitySourceName,
            Utils.TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(AppContext.BaseDirectory)),
            tagsSource);
 
        telemetry.Initialize();
        await telemetry.CompleteInternalMicrosoftDiagnosticsAsync().WaitAsync(TimeSpan.FromSeconds(1));
 
        Assert.False(tagsSource.TagsTask.IsCompleted);
        blockedTag.TrySetResult("test-device-id");
    }
 
    [Fact]
    public async Task Initialize_BoundsInternalMicrosoftDetectorForAgentTelemetryInvocation()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            DetectionCallback = async cancellationToken =>
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
                throw new UnreachableException();
            }
        };
        var tagsSource = new TelemetryTagsSource(NullLogger<TelemetryTagsSource>.Instance);
        var telemetry = new AspireCliTelemetry(
            NullLogger<AspireCliTelemetry>.Instance,
            new TelemetryFixture.TestMachineInformationProvider(),
            new TelemetryFixture.TestCIEnvironmentDetector(),
            new TelemetryFixture.TestCodingAgentDetector(),
            internalMicrosoftDetector,
            new TelemetryConfiguration
            {
                ReportedTelemetryEnabled = true,
                EmitInternalMicrosoftDiagnostics = false,
                InternalMicrosoftDetectionTimeout = TimeSpan.FromMilliseconds(25)
            },
            AspireCliTelemetry.ReportedActivitySourceName,
            AspireCliTelemetry.DiagnosticsActivitySourceName,
            Utils.TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(AppContext.BaseDirectory)),
            tagsSource);
 
        telemetry.Initialize();
        await tagsSource.TagsTask;
 
        Assert.Equal(1, internalMicrosoftDetector.InvocationCount);
        Assert.Contains(
            await telemetry.GetDefaultTagsAsync(),
            tag => tag.Key == TelemetryConstants.Tags.InternalMicrosoft && (bool?)tag.Value == false);
    }
 
    [Fact]
    public async Task Initialize_AddsDefaultTags_WhenInternalMicrosoftDetectorFails()
    {
        var provider = new TelemetryFixture.TestMachineInformationProvider
        {
            DeviceId = "test-device-id",
            MacAddressHash = "test-mac-hash"
        };
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            ExceptionToThrow = new NotSupportedException("Unexpected probe failure.")
        };
        var tagsSource = new TelemetryTagsSource(NullLogger<TelemetryTagsSource>.Instance);
        var telemetry = new AspireCliTelemetry(
            NullLogger<AspireCliTelemetry>.Instance,
            provider,
            new TelemetryFixture.TestCIEnvironmentDetector(),
            new TelemetryFixture.TestCodingAgentDetector(),
            internalMicrosoftDetector,
            new TelemetryConfiguration { ReportedTelemetryEnabled = true },
            AspireCliTelemetry.ReportedActivitySourceName,
            AspireCliTelemetry.DiagnosticsActivitySourceName,
            Utils.TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(AppContext.BaseDirectory)),
            tagsSource);
 
        telemetry.Initialize();
        await tagsSource.TagsTask;
 
        var tags = await telemetry.GetDefaultTagsAsync();
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.MacAddressHash && (string?)t.Value == "test-mac-hash");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.DeviceId && (string?)t.Value == "test-device-id");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.CliVersion);
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.OsName);
        Assert.Collection(
            GetInternalMicrosoftTags(tags),
            tag =>
            {
                Assert.Equal(TelemetryConstants.Tags.InternalMicrosoft, tag.Key);
                Assert.False((bool?)tag.Value);
            });
    }
 
    [Fact]
    public async Task Initialize_RecordsElapsedDurationWhenInternalMicrosoftDetectorFails()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            DetectionCallback = async cancellationToken =>
            {
                await Task.Delay(TimeSpan.FromMilliseconds(20), cancellationToken);
                throw new NotSupportedException("Unexpected probe failure.");
            }
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
        await fixture.Telemetry.CompleteInternalMicrosoftDiagnosticsAsync();
 
        var activity = Assert.IsType<Activity>(fixture.CapturedActivity);
        Assert.Equal(InternalMicrosoftDetectorOutcome.Failed, activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorOutcome));
        Assert.True((long?)activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorDurationMs) > 0);
    }
 
    [Theory]
    [InlineData(25)]
    [InlineData(500)]
    public async Task GetInternalMicrosoftResultAsync_RecordsActualElapsedDurationWhenDetectorTimesOut(int elapsedMilliseconds)
    {
        var timeProvider = new FakeTimeProvider();
        var timeout = TimeSpan.FromMilliseconds(25);
        using var timeoutSource = new CancellationTokenSource(timeout, timeProvider);
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            DetectionCallback = async cancellationToken =>
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
                throw new UnreachableException();
            }
        };
        using var fixture = new TelemetryFixture(
            internalMicrosoftDetector: internalMicrosoftDetector,
            initialize: false);
        var resultTask = fixture.Telemetry.GetInternalMicrosoftResultAsync(timeoutSource, timeProvider);
 
        Assert.False(resultTask.IsCompleted);
        // Cancellation can be observed later than the budget under contention. Keep virtual time
        // fixed until the wrapper finishes so scheduling cannot affect the recorded duration.
        var elapsed = TimeSpan.FromMilliseconds(elapsedMilliseconds);
        timeProvider.Advance(elapsed);
        var result = await resultTask.WaitAsync(TimeSpan.FromSeconds(30));
 
        // Completing unrelated tag calculation later must not extend the already captured duration.
        timeProvider.Advance(TimeSpan.FromSeconds(1));
        Assert.Equal(InternalMicrosoftDetectorOutcome.TimedOut, result.Outcome);
        Assert.Equal(elapsed, result.Duration);
    }
 
    [Fact]
    public async Task CompleteInternalMicrosoftDiagnosticsAsync_ContainsListenerFailureDuringShutdown()
    {
        var logger = new FakeLogger<AspireCliTelemetry>();
        using var fixture = new TelemetryFixture(logger: logger, initialize: false);
        var exception = new InvalidOperationException("Simulated detector activity listener failure.");
        var listenerCalled = false;
        using var listener = new ActivityListener
        {
            ShouldListenTo = source => source.Name == fixture.ReportedSourceName,
            Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
            ActivityStopped = activity =>
            {
                if (activity.OperationName == TelemetryConstants.Activities.InternalMicrosoftDetector)
                {
                    listenerCalled = true;
                    throw exception;
                }
            }
        };
        ActivitySource.AddActivityListener(listener);
 
        fixture.Telemetry.Initialize();
        await fixture.Telemetry.CompleteInternalMicrosoftDiagnosticsAsync().WaitAsync(TimeSpan.FromSeconds(30));
        await fixture.Telemetry.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.True(listenerCalled);
        var log = Assert.Single(logger.Collector.GetSnapshot());
        Assert.Equal(LogLevel.Debug, log.Level);
        Assert.Equal("Failed to complete internal Microsoft diagnostics.", log.Message);
        Assert.Same(exception, log.Exception);
    }
 
    [Fact]
    public async Task CompleteInternalMicrosoftDiagnosticsAsync_PreservesCallerCancellation()
    {
        var deviceId = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var fixture = new TelemetryFixture(
            machineInfoProvider: new TelemetryFixture.TestMachineInformationProvider
            {
                GetDeviceIdCallback = () => deviceId.Task
            },
            initialize: false);
        using var cancellationSource = new CancellationTokenSource();
        fixture.Telemetry.Initialize();
        var completionTask = fixture.Telemetry.CompleteInternalMicrosoftDiagnosticsAsync();
        var stopTask = fixture.Telemetry.StopAsync(cancellationSource.Token);
 
        try
        {
            await cancellationSource.CancelAsync();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stopTask);
            Assert.False(completionTask.IsCompleted);
        }
        finally
        {
            deviceId.TrySetResult("test-device-id");
            await completionTask.WaitAsync(TimeSpan.FromSeconds(30));
        }
    }
 
    [Fact]
    public void CompleteInternalMicrosoftDiagnosticsAsync_ReusesOneCompletionWait()
    {
        using var fixture = new TelemetryFixture();
 
        var firstWait = fixture.Telemetry.CompleteInternalMicrosoftDiagnosticsAsync();
        var secondWait = fixture.Telemetry.CompleteInternalMicrosoftDiagnosticsAsync();
 
        Assert.Same(firstWait, secondWait);
    }
 
    [Fact]
    public void Initialize_EmitsBoundedInternalMicrosoftDiagnosticActivity()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            IsInternalMicrosoft = true,
            Source = "test source",
            Alias = "test.alias",
            Domain = "TEST",
            ProbeDiagnostics =
            [
                new InternalMicrosoftProbeDiagnostic("test source", InternalMicrosoftProbeOutcome.Detected, TimeSpan.FromMilliseconds(12), HasAlias: true, HasDomain: true)
            ]
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
 
        var activity = fixture.CapturedActivity;
 
        Assert.NotNull(activity);
        Assert.Equal(TelemetryConstants.Activities.InternalMicrosoftDetector, activity.OperationName);
        Assert.Equal(fixture.ReportedSourceName, activity.Source.Name);
        Assert.Equal(InternalMicrosoftDetectorOutcome.Detected, activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorOutcome));
        Assert.Equal(InternalMicrosoftDetectorCacheStatus.Miss, activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorCacheStatus));
        Assert.Equal("test source", activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftSource));
        Assert.True((bool?)activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorHasAlias));
        Assert.True((bool?)activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDetectorHasDomain));
        Assert.Null(activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftAlias));
        Assert.Null(activity.GetTagItem(TelemetryConstants.Tags.InternalMicrosoftDomain));
        var probeEvent = Assert.Single(activity.Events);
        Assert.Equal(TelemetryConstants.Events.InternalMicrosoftProbe, probeEvent.Name);
        Assert.Contains(probeEvent.Tags, tag => tag.Key == TelemetryConstants.Tags.InternalMicrosoftProbeOutcome && (string?)tag.Value == InternalMicrosoftProbeOutcome.Detected);
    }
 
    [Fact]
    public void Initialize_EmitsOnlyAllowListedProbeFailureMetadata()
    {
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector
        {
            ProbeDiagnostics =
            [
                new InternalMicrosoftProbeDiagnostic(
                    "test source",
                    InternalMicrosoftProbeOutcome.Failed,
                    TimeSpan.FromMilliseconds(18),
                    HasAlias: false,
                    HasDomain: false,
                    Failure: new InternalMicrosoftProbeFailure(
                        InternalMicrosoftProbeFailureCode.ProcessExit,
                        InternalMicrosoftProbeFailureStage.ProcessExit,
                        ExceptionType: InternalMicrosoftProbeExceptionType.Other,
                        ProcessExitCode: 7,
                        HttpStatusCode: 503))
            ]
        };
        using var fixture = new TelemetryFixture(internalMicrosoftDetector: internalMicrosoftDetector);
 
        var probeEvent = Assert.Single(fixture.CapturedActivity!.Events);
        var tags = probeEvent.Tags.ToDictionary(tag => tag.Key, tag => tag.Value);
 
        Assert.Equal(InternalMicrosoftProbeFailureCode.ProcessExit, tags[TelemetryConstants.Tags.InternalMicrosoftProbeFailureCode]);
        Assert.Equal(InternalMicrosoftProbeFailureStage.ProcessExit, tags[TelemetryConstants.Tags.InternalMicrosoftProbeFailureStage]);
        Assert.Equal(InternalMicrosoftProbeExceptionType.Other, tags[TelemetryConstants.Tags.InternalMicrosoftProbeExceptionType]);
        Assert.Equal(7, tags[TelemetryConstants.Tags.InternalMicrosoftProbeProcessExitCode]);
        Assert.Equal(503, tags[TelemetryConstants.Tags.InternalMicrosoftProbeHttpStatusCode]);
        Assert.DoesNotContain(tags, tag => tag.Key.Contains("message", StringComparison.OrdinalIgnoreCase));
        Assert.DoesNotContain(tags, tag => tag.Key.Contains("path", StringComparison.OrdinalIgnoreCase));
    }
 
    [Theory]
    [InlineData("CLAUDECODE", null, "1", null)]
    [InlineData("CLAUDECODE", "1", "", "claude")]
    [InlineData("CLAUDECODE", "1", null, "claude")]
    [InlineData("AI_AGENT", null, "github_copilot_app_agent", null)]
    [InlineData("AI_AGENT", "github_copilot_app_agent", "github_copilot_vscode_agent", "copilot-app")]
    [InlineData("OR_APP_NAME", "Aider", "plandex", "aider")]
    public void CodingAgentDetector_IgnoresConfigurationValues(string variableName, string? environmentValue, string? configuredValue, string? expectedCodingAgent)
    {
        var environmentVariables = new Dictionary<string, string?>
        {
            [variableName] = environmentValue
        };
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(environmentVariables)
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [variableName] = configuredValue
            })
            .Build();
        var services = new ServiceCollection();
        services.AddSingleton<IConfiguration>(configuration);
        services.AddSingleton<IEnvironment>(new TestEnvironment(environmentVariables));
        services.AddTelemetryServices();
        using var serviceProvider = services.BuildServiceProvider();
 
        var detector = serviceProvider.GetRequiredService<ICodingAgentDetector>();
 
        Assert.Equal(expectedCodingAgent, detector.GetCodingAgent());
    }
 
    [Theory]
    [MemberData(nameof(CodingAgentTelemetryTestCases))]
    public void CodingAgentDetector_DetectsKnownCodingAgents((string, string?)[] environmentVariables, string? expectedCodingAgent)
    {
        var environment = new TestEnvironment(environmentVariables.ToDictionary(variable => variable.Item1, variable => variable.Item2, StringComparer.Ordinal));
        var detector = new CodingAgentDetector(environment);
 
        Assert.Equal(expectedCodingAgent, detector.GetCodingAgent());
    }
 
    [Theory]
    [InlineData(true, "claude")]
    [InlineData(false, null)]
    public void CodingAgentDetector_PreservesEnvironmentVariableNameComparison(bool ignoreCase, string? expectedCodingAgent)
    {
        var comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
        var environment = new TestEnvironment(new Dictionary<string, string?>(comparer)
        {
            ["claudecode"] = "1"
        });
        var detector = new CodingAgentDetector(environment);
 
        Assert.Equal(expectedCodingAgent, detector.GetCodingAgent());
    }
 
    [Fact]
    public void CodingAgentDetector_ReadsCurrentEnvironmentValues()
    {
        var environmentVariables = new Dictionary<string, string?>();
        var detector = new CodingAgentDetector(new TestEnvironment(environmentVariables));
 
        Assert.Null(detector.GetCodingAgent());
 
        environmentVariables["AI_AGENT"] = "github_copilot_app_agent";
        Assert.Equal("copilot-app", detector.GetCodingAgent());
 
        environmentVariables["AI_AGENT"] = "github_copilot_vscode_agent";
        Assert.Equal("copilot-vscode", detector.GetCodingAgent());
 
        environmentVariables.Clear();
        Assert.Null(detector.GetCodingAgent());
    }
 
    [Fact]
    public async Task StartReportedActivity_IncludesAllDefaultTags()
    {
        var machineInfoProvider = new TelemetryFixture.TestMachineInformationProvider
        {
            DeviceId = "test-device-id",
            MacAddressHash = "test-mac-hash"
        };
        using var fixture = new TelemetryFixture(machineInfoProvider, sampleResult: ActivitySamplingResult.AllData);
 
        using var activity = fixture.Telemetry.StartReportedActivity("test-activity");
 
        Assert.NotNull(activity);
 
        // Verify all default tags are included
        var defaultTags = await fixture.Telemetry.GetDefaultTagsAsync();
        var activityTags = activity.TagObjects.ToDictionary(t => t.Key, t => t.Value);
        foreach (var tag in defaultTags)
        {
            Assert.True(activityTags.ContainsKey(tag.Key), $"Activity is missing tag '{tag.Key}'");
            Assert.Equal(tag.Value, activityTags[tag.Key]);
        }
    }
 
    [Fact]
    public async Task Initialize_IsIdempotent()
    {
        var provider = new TelemetryFixture.TestMachineInformationProvider();
        var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector();
        var codingAgentDetector = new TelemetryFixture.TestCodingAgentDetector();
        var internalMicrosoftDetector = new TelemetryFixture.TestInternalMicrosoftDetector();
        var tagsSource = new TelemetryTagsSource(NullLogger<TelemetryTagsSource>.Instance);
        var telemetry = new AspireCliTelemetry(
            NullLogger<AspireCliTelemetry>.Instance,
            provider,
            ciDetector,
            codingAgentDetector,
            internalMicrosoftDetector,
            new TelemetryConfiguration { ReportedTelemetryEnabled = true },
            AspireCliTelemetry.ReportedActivitySourceName,
            AspireCliTelemetry.DiagnosticsActivitySourceName,
            Utils.TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(AppContext.BaseDirectory)),
            tagsSource);
 
        telemetry.Initialize();
        await tagsSource.TagsTask;
        var tagsAfterFirstInit = (await telemetry.GetDefaultTagsAsync()).Count;
        telemetry.Initialize(); // Should not throw
 
        var tags = await telemetry.GetDefaultTagsAsync();
        Assert.Equal(tagsAfterFirstInit, tags.Count); // Should have the same number of tags after second init
    }
 
    private static IReadOnlyList<KeyValuePair<string, object?>> GetInternalMicrosoftTags(IReadOnlyList<KeyValuePair<string, object?>> tags)
    {
        return [.. tags.Where(t => t.Key == TelemetryConstants.Tags.InternalMicrosoft ||
            t.Key is TelemetryConstants.Tags.InternalMicrosoftSource or TelemetryConstants.Tags.InternalMicrosoftAlias or TelemetryConstants.Tags.InternalMicrosoftDomain)];
    }
 
    [Fact]
    public async Task Initialize_AddsIdentityTags_WhenExecutionContextProvided()
    {
        // The execution context only needs a valid root for path composition; telemetry init
        // does not touch the filesystem for identity, so reuse the test base directory.
        var executionContext = Utils.TestExecutionContextHelper.CreateExecutionContext(
            new DirectoryInfo(AppContext.BaseDirectory),
            identityChannel: "daily",
            identityVersion: "13.5.0-preview.1.26310.9",
            identityCommit: "95f0d2968",
            identityOverridden: true);
 
        using var fixture = new TelemetryFixture(executionContext: executionContext);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.IdentityVersion && (string?)t.Value == "13.5.0-preview.1.26310.9");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.IdentityChannel && (string?)t.Value == "daily");
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.IdentityCommit && (string?)t.Value == "95f0d2968");
 
        // The binary tags must remain distinct from the identity tags so an emulated run is
        // distinguishable from the physical binary that produced the telemetry.
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.CliVersion);
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.CliBuildId);
    }
 
    [Fact]
    public async Task Initialize_OmitsIdentityCommitTag_WhenCommitIsEmpty()
    {
        var executionContext = Utils.TestExecutionContextHelper.CreateExecutionContext(
            new DirectoryInfo(AppContext.BaseDirectory),
            identityChannel: "stable",
            identityVersion: "13.5.0",
            identityCommit: null,
            identityOverridden: true);
 
        using var fixture = new TelemetryFixture(executionContext: executionContext);
 
        var tags = await fixture.Telemetry.GetDefaultTagsAsync();
 
        Assert.Contains(tags, t => t.Key == TelemetryConstants.Tags.IdentityVersion && (string?)t.Value == "13.5.0");
        Assert.DoesNotContain(tags, t => t.Key == TelemetryConstants.Tags.IdentityCommit);
    }
 
    public static TheoryData<(string, string?)[], string?> CodingAgentTelemetryTestCases => new()
    {
        { [("CLAUDECODE", "1")], "claude" },
        { [("CLAUDECODE", null)], null },
        { [("CLAUDECODE", "")], null },
        { [("CLAUDECODE", " ")], "claude" },
        { [("CLAUDECODE", "1"), ("CLAUDE_CODE", "1")], "claude" },
        { [("CLAUDE_CODE", "1")], "claude" },
        { [("CLAUDE_CODE_ENTRYPOINT", "some_value")], "claude" },
        { [("CLAUDE_CODE_IS_COWORK", "1")], "cowork" },
        { [("CURSOR_EDITOR", "1")], "cursor" },
        { [("CURSOR_AI", "1")], "cursor" },
        { [("CURSOR_TRACE_ID", "abc")], "cursor" },
        { [("CURSOR_AGENT", "1")], "cursor" },
        { [("GEMINI_CLI", "true")], "gemini" },
        { [("GEMINI_CLI", "0")], "gemini" },
        { [("GITHUB_COPILOT_CLI_MODE", "true")], "copilot-cli" },
        { [("GH_COPILOT_WORKING_DIRECTORY", "/repo")], "copilot-cli" },
        { [("COPILOT_CLI", "1")], "copilot-cli" },
        { [("COPILOT_MODEL", "gpt")], "copilot-cli" },
        { [("COPILOT_ALLOW_ALL", "1")], "copilot-cli" },
        { [("COPILOT_GITHUB_TOKEN", "token")], "copilot-cli" },
        { [("AI_AGENT", "github_copilot_app_agent")], "copilot-app" },
        { [("AI_AGENT", "GITHUB_COPILOT_APP_AGENT")], "copilot-app" },
        { [("AI_AGENT", " github_copilot_app_agent ")], null },
        { [("AI_AGENT", "unknown_agent")], null },
        { [("AI_AGENT", "")], null },
        { [("AI_AGENT", null)], null },
        { [("AI_AGENT", "github_copilot_vscode_agent")], "copilot-vscode" },
        { [("COPILOT_AGENT", "1")], "copilot-vscode" },
        { [("AI_AGENT", "github_copilot_vscode_agent"), ("COPILOT_AGENT", "1")], "copilot-vscode" },
        { [("CODEX_CLI", "1")], "codex" },
        { [("CODEX_SANDBOX", "1")], "codex" },
        { [("CODEX_CI", "1")], "codex" },
        { [("CODEX_THREAD_ID", "thread1")], "codex" },
        { [("OR_APP_NAME", "Aider")], "aider" },
        { [("OR_APP_NAME", "aider")], "aider" },
        { [("OR_APP_NAME", " Aider ")], null },
        { [("OR_APP_NAME", "plandex")], "plandex" },
        { [("OR_APP_NAME", "Plandex")], "plandex" },
        { [("AMP_HOME", "/path/to/amp")], "amp" },
        { [("QWEN_CODE", "1")], "qwen" },
        { [("DROID_CLI", "true")], "droid" },
        { [("OPENCODE_AI", "1")], "opencode" },
        { [("ZED_ENVIRONMENT", "1")], "zed" },
        { [("ZED_TERM", "1")], "zed" },
        { [("KIMI_CLI", "true")], "kimi" },
        { [("OR_APP_NAME", "OpenHands")], "openhands" },
        { [("OR_APP_NAME", "openhands")], "openhands" },
        { [("GOOSE_TERMINAL", "1")], "goose" },
        { [("GOOSE_PROVIDER", "openai")], "goose" },
        { [("CLINE_TASK_ID", "task123")], "cline" },
        { [("ROO_CODE_TASK_ID", "task456")], "roo" },
        { [("WINDSURF_SESSION", "session789")], "windsurf" },
        { [("REPL_ID", "repl1")], "replit" },
        { [("AUGMENT_AGENT", "1")], "augment" },
        { [("ANTIGRAVITY_AGENT", "1")], "antigravity" },
        { [("AGENT_CLI", "true")], "generic_agent" },
        { [("CLAUDECODE", "1"), ("CURSOR_EDITOR", "1") ], "claude, cursor" },
        { [("GEMINI_CLI", "true"), ("GITHUB_COPILOT_CLI_MODE", "true") ], "gemini, copilot-cli" },
        { [("CLAUDECODE", "1"), ("GEMINI_CLI", "true"), ("AGENT_CLI", "true") ], "claude, gemini, generic_agent" },
        { [("CLAUDECODE", "1"), ("CURSOR_EDITOR", "1"), ("GEMINI_CLI", "true"), ("GITHUB_COPILOT_CLI_MODE", "true"), ("AGENT_CLI", "true") ], "claude, cursor, gemini, copilot-cli, generic_agent" },
        { [("OR_APP_NAME", "Aider"), ("CLINE_TASK_ID", "task123") ], "aider, cline" },
        { [("CODEX_CLI", "1"), ("WINDSURF_SESSION", "session789") ], "codex, windsurf" },
        { [("GOOSE_TERMINAL", "1"), ("ROO_CODE_TASK_ID", "task456") ], "goose, roo" },
        { [("COPILOT_CLI", "1"), ("AI_AGENT", "github_copilot_app_agent")], "copilot-cli, copilot-app" },
        { [("GEMINI_CLI", "false")], "gemini" },
        { [("GITHUB_COPILOT_CLI_MODE", "false")], "copilot-cli" },
        { [("AGENT_CLI", "false")], "generic_agent" },
        { [("DROID_CLI", "false")], "droid" },
        { [("KIMI_CLI", "false")], "kimi" },
        { [("CLAUDE_CODE_IS_COWORK", "1"), ("CLAUDE_CODE", "1")], "cowork, claude" },
        { [("OR_APP_NAME", "SomeOtherApp")], null },
        { [], null }
    };
}