File: Telemetry\DashboardTelemetryManagerTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Dashboard.Telemetry;
using Aspire.Shared;
using Aspire.Shared.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Telemetry;
 
public class DashboardTelemetryManagerTests
{
    [Fact]
    public async Task ManagerDisposal_ReleasesProductLoggingWithoutDisposingApplicationLogging()
    {
        var sink = new TestSink();
        using var factory = LoggerFactory.Create(builder => builder.AddProvider(new TestLoggerProvider(sink)));
        using var telemetry = new DashboardTelemetryService(factory.CreateLogger<DashboardTelemetryService>(),
            new DashboardTelemetryConfiguration { ReportedTelemetryEnabled = true });
        var exporter = new TestDashboardTelemetryLogExporter();
        var configured = 0;
        using var source = new ActivitySource($"Test.Dashboard.Ownership.{Guid.NewGuid():N}");
        await using var manager = new DashboardTelemetryManager(
            new DashboardTelemetryConfiguration { ReportedTelemetryEnabled = true },
            factory.CreateLogger<DashboardTelemetryManager>(), telemetry,
            (resource, _) => AzureMonitorTelemetryProvider.Create(new ServiceCollection(), resource,
                DashboardTelemetryService.EventLogCategoryName,
                () => Sdk.CreateTracerProviderBuilder().AddSource(source.Name).Build(), provider =>
                {
                    configured++;
                    provider.AddProcessor(new SimpleLogRecordExportProcessor(exporter));
                }));
        manager.Initialize();
        manager.Initialize();
        Assert.Equal(1, configured);
        Assert.True(source.HasListeners());
 
        telemetry.RecordEvent(TelemetryEventKeys.ComponentInitialize);
        Assert.True(exporter.LogChannel.Reader.TryRead(out var log));
        Assert.Equal(TelemetryEventKeys.ComponentInitialize, log.Message);
        Assert.Empty(sink.Writes);
 
        telemetry.Dispose();
        telemetry.Dispose();
        Assert.False(exporter.IsDisposed);
        Assert.True(source.HasListeners());
        await manager.DisposeAsync();
        await manager.DisposeAsync();
 
        Assert.True(exporter.IsDisposed);
        Assert.False(source.HasListeners());
        Assert.Throws<ObjectDisposedException>(manager.Initialize);
        factory.CreateLogger("Microsoft.AspNetCore").LogWarning("Still logging locally");
        Assert.Equal("Still logging locally", Assert.Single(sink.Writes).Message);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task Initialize_UsesAzureResourceOnlyWhenEnabled(bool enabled)
    {
        Resource? traceResource = null;
        Resource? logResource = null;
        var services = new ServiceCollection();
        ConfigureServices(services, enabled);
        services.AddSingleton(services => new DashboardTelemetryManager(
            services.GetRequiredService<DashboardTelemetryConfiguration>(),
            services.GetRequiredService<ILogger<DashboardTelemetryManager>>(),
            services.GetRequiredService<DashboardTelemetryService>(),
            (resource, _) => AzureMonitorTelemetryProvider.Create(new ServiceCollection(), resource, DashboardTelemetryService.EventLogCategoryName,
                () =>
                {
                    var provider = Sdk.CreateTracerProviderBuilder().SetResourceBuilder(resource).Build();
                    traceResource = provider.GetResource();
                    return provider;
                },
                provider =>
                {
                    Assert.NotNull(traceResource);
                    logResource = provider.GetResource();
                })));
        await using var serviceProvider = services.BuildServiceProvider();
        var manager = serviceProvider.GetRequiredService<DashboardTelemetryManager>();
 
        manager.Initialize();
 
        Assert.True(manager.IsInitialized);
        if (enabled)
        {
            Assert.NotNull(traceResource);
            Assert.NotNull(logResource);
            Assert.Equal(traceResource.Attributes.ToArray(), logResource.Attributes.ToArray());
            var expectedVersion = AssemblyVersionHelper.GetInformationalVersion(typeof(DashboardWebApplication).Assembly);
            Assert.NotEmpty(expectedVersion);
            Assert.Collection(traceResource.Attributes.OrderBy(attribute => attribute.Key, StringComparer.Ordinal),
                attribute =>
                {
                    Assert.Equal("service.instance.id", attribute.Key);
                    Assert.True(Guid.TryParse(Assert.IsType<string>(attribute.Value), out var instanceId));
                    Assert.NotEqual(Guid.Empty, instanceId);
                },
                attribute => Assert.Equal(new KeyValuePair<string, object>("service.name", "aspire-dashboard"), attribute),
                attribute => Assert.Equal(new KeyValuePair<string, object>("service.version", expectedVersion), attribute));
        }
        else
        {
            Assert.Null(traceResource);
            Assert.Null(logResource);
        }
        Assert.Null(serviceProvider.GetService<LoggerProvider>());
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task Initialize_ConcurrentFirstCalls_AreIdempotentAndHonorEnablement(bool enabled)
    {
        await using var services = CreateServices(enabled);
        var manager = services.GetRequiredService<DashboardTelemetryManager>();
        var telemetry = services.GetRequiredService<DashboardTelemetryService>();
        Assert.Same(manager, Assert.Single(services.GetServices<IHostedService>()));
        Assert.False(manager.IsInitialized);
        Assert.Equal(enabled, telemetry.IsTelemetryEnabled);
 
        var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var initializations = Enumerable.Range(0, 8).Select(_ => Task.Run(async () =>
        {
            await start.Task;
            manager.Initialize();
        })).ToArray();
        start.SetResult();
        await Task.WhenAll(initializations);
 
        Assert.True(manager.IsInitialized);
        Assert.Equal(enabled, telemetry.IsTelemetryEnabled);
 
        await manager.StartAsync(CancellationToken.None);
 
        Assert.True(manager.IsInitialized);
        Assert.Equal(enabled, telemetry.IsTelemetryEnabled);
        if (enabled)
        {
            using var source = new ActivitySource(DashboardTelemetryService.ReportedActivitySourceName);
            Assert.True(source.HasListeners());
        }
 
        var shutdownTask = manager.StopAsync(CancellationToken.None);
        Assert.Same(shutdownTask, manager.StopAsync(CancellationToken.None));
        Assert.Same(shutdownTask, manager.DisposeAsync().AsTask());
        await shutdownTask;
 
        Assert.False(manager.IsInitialized);
        Assert.Equal(enabled, telemetry.IsTelemetryEnabled);
        Assert.Throws<ObjectDisposedException>(manager.Initialize);
    }
 
    [Fact]
    public async Task Dispose_BeforeInitialization_PreventsStartup()
    {
        await using var services = CreateServices(enabled: true);
        var manager = services.GetRequiredService<DashboardTelemetryManager>();
 
        await manager.DisposeAsync();
 
        Assert.False(manager.IsInitialized);
        Assert.Throws<ObjectDisposedException>(manager.Initialize);
    }
 
    [Theory]
    [InlineData(true, true)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(false, false)]
    public async Task StartAsync_ExporterInitializationFails_LogsAndAllowsHostStartup(bool failTrace, bool retry)
    {
        var sink = new TestSink();
        var failure = new UnauthorizedAccessException("Telemetry storage is not writable.");
        var failInitialization = true;
        var logExporters = new List<TestDashboardTelemetryLogExporter>();
        using var source = new ActivitySource($"Test.Dashboard.Startup.{Guid.NewGuid():N}");
        using var host = new HostBuilder().ConfigureServices(services =>
        {
            ConfigureServices(services, enabled: true);
            services.AddLogging(builder => builder.AddProvider(new TestLoggerProvider(sink)));
            services.AddSingleton(services => new DashboardTelemetryManager(
                services.GetRequiredService<DashboardTelemetryConfiguration>(),
                services.GetRequiredService<ILogger<DashboardTelemetryManager>>(),
                services.GetRequiredService<DashboardTelemetryService>(),
                (resource, _) => AzureMonitorTelemetryProvider.Create(new ServiceCollection(), resource, DashboardTelemetryService.EventLogCategoryName,
                    () =>
                    {
                        if (failTrace && failInitialization)
                        {
                            throw failure;
                        }
 
                        return Sdk.CreateTracerProviderBuilder().AddSource(source.Name).Build();
                    },
                    provider =>
                    {
                        var exporter = new TestDashboardTelemetryLogExporter();
                        logExporters.Add(exporter);
                        provider.AddProcessor(new SimpleLogRecordExportProcessor(exporter));
                        if (!failTrace && failInitialization)
                        {
                            throw failure;
                        }
                    })));
        }).Build();
        var manager = host.Services.GetRequiredService<DashboardTelemetryManager>();
 
        await host.StartAsync(CancellationToken.None);
 
        Assert.True(host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStarted.IsCancellationRequested);
        Assert.False(manager.IsInitialized);
        Assert.False(source.HasListeners());
        Assert.All(logExporters, exporter => Assert.True(exporter.IsDisposed));
        Assert.True(host.Services.GetRequiredService<DashboardTelemetryService>().IsTelemetryEnabled);
        var warning = Assert.Single(sink.Writes, write => write.LoggerName == typeof(DashboardTelemetryManager).FullName);
        Assert.Equal(LogLevel.Warning, warning.LogLevel);
        Assert.Same(failure, warning.Exception);
        Assert.Equal("Failed to initialize dashboard product telemetry. The dashboard will continue without product export.", warning.Message);
 
        if (retry)
        {
            failInitialization = false;
            manager.Initialize();
            Assert.True(manager.IsInitialized);
            Assert.True(source.HasListeners());
        }
 
        await host.StopAsync(CancellationToken.None);
        Assert.False(manager.IsInitialized);
        Assert.False(source.HasListeners());
        var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Microsoft.AspNetCore");
        logger.LogWarning("Still logging locally");
        Assert.Single(sink.Writes, write => write.Message == "Still logging locally");
        Assert.Throws<ObjectDisposedException>(manager.Initialize);
    }
 
    private static ServiceProvider CreateServices(bool enabled)
    {
        var services = new ServiceCollection();
        ConfigureServices(services, enabled);
 
        // Lifecycle tests exercise real SDK providers without creating Azure exporters.
        services.AddSingleton(services => new DashboardTelemetryManager(
            services.GetRequiredService<DashboardTelemetryConfiguration>(),
            services.GetRequiredService<ILogger<DashboardTelemetryManager>>(),
            services.GetRequiredService<DashboardTelemetryService>(),
            (resource, _) => AzureMonitorTelemetryProvider.Create(new ServiceCollection(), resource, DashboardTelemetryService.EventLogCategoryName,
                () => Sdk.CreateTracerProviderBuilder().AddSource(DashboardTelemetryService.ReportedActivitySourceName).Build(), _ => { })));
 
        return services.BuildServiceProvider();
    }
 
    private static void ConfigureServices(IServiceCollection services, bool enabled)
    {
        services.AddSingleton(new DashboardTelemetryConfiguration { ReportedTelemetryEnabled = enabled });
        services.AddLogging();
        services.AddSingleton<DashboardTelemetryService>();
        services.AddSingleton<DashboardTelemetryManager>();
        services.AddHostedService(services => services.GetRequiredService<DashboardTelemetryManager>());
 
    }
}