File: Integration\HealthTests.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 System.Net;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Trace;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration;
 
public class HealthTests(HealthTests.Fixture fixture) : IClassFixture<HealthTests.Fixture>
{
    private const string TestActivitySourceName = "Aspire.Dashboard.Tests.Integration.HealthTests";
 
    [Fact]
    public async Task HealthEndpoint_SendRequest_200Response()
    {
        await MakeRequestAndAssert($"http://{fixture.App.FrontendSingleEndPointAccessor().EndPoint}", HttpVersion.Version11).DefaultTimeout();
        await MakeRequestAndAssert($"http://{fixture.App.OtlpServiceHttpEndPointAccessor().EndPoint}", HttpVersion.Version11).DefaultTimeout();
        await MakeRequestAndAssert($"http://{fixture.App.OtlpServiceGrpcEndPointAccessor().EndPoint}", HttpVersion.Version20).DefaultTimeout();
 
        static async Task MakeRequestAndAssert(string basePath, Version httpVersion)
        {
            using var httpClientHandler = new HttpClientHandler { AllowAutoRedirect = false };
            using var client = new HttpClient(httpClientHandler) { BaseAddress = new Uri(basePath) };
 
            // Act
            var request = new HttpRequestMessage(HttpMethod.Get, $"/{DashboardUrls.HealthBasePath}");
            request.Version = httpVersion;
            request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
            var response = await client.SendAsync(request).DefaultTimeout();
 
            // Assert 
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }
    }
 
    [Fact]
    public async Task HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity()
    {
        using var testActivity = fixture.TestActivitySource.StartActivity(nameof(HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity));
        Assert.NotNull(testActivity);
        using var observation = fixture.Exporter.ObserveNext(
            testActivity.TraceId,
            activity => activity.Source.Name == "Microsoft.AspNetCore" && activity.Kind == ActivityKind.Server);
 
        using var client = new HttpClient { BaseAddress = new Uri($"http://{fixture.App.FrontendSingleEndPointAccessor().EndPoint}") };
        var response = await client.GetAsync($"/{DashboardUrls.HealthBasePath}").DefaultTimeout();
 
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var activity = await observation.Task.DefaultTimeout();
        Assert.Equal(testActivity.TraceId, activity.TraceId);
        Assert.Equal("Microsoft.AspNetCore", activity.Source.Name);
        Assert.Equal(ActivityKind.Server, activity.Kind);
    }
 
    [Fact]
    public async Task OtlpExporterConfigured_ExportsResourceServiceActivity()
    {
        using var testActivity = fixture.TestActivitySource.StartActivity(nameof(OtlpExporterConfigured_ExportsResourceServiceActivity));
        Assert.NotNull(testActivity);
        using var observation = fixture.Exporter.ObserveNext(
            testActivity.TraceId,
            activity => activity.Source.Name == DashboardActivitySource.ActivitySourceName);
        var activitySource = fixture.App.Services.GetRequiredService<DashboardActivitySource>();
 
        using (var activity = activitySource.ActivitySource.StartActivity("Test resource update", ActivityKind.Consumer))
        {
            Assert.NotNull(activity);
        }
 
        var exported = await observation.Task.DefaultTimeout();
        Assert.Equal(testActivity.TraceId, exported.TraceId);
        Assert.Equal(DashboardActivitySource.ActivitySourceName, exported.Source.Name);
        Assert.Equal(ActivityKind.Consumer, exported.Kind);
    }
 
    [Theory]
    [InlineData(null, null, "aspire-dashboard", null)]
    [InlineData("custom-dashboard", null, "custom-dashboard", null)]
    [InlineData(null, "service.name=resource-dashboard", "resource-dashboard", null)]
    [InlineData(null, "service.name=resource-dashboard,service.instance.id=stable-instance", "resource-dashboard", "stable-instance")]
    [InlineData("custom-dashboard", "service.name=resource-dashboard,service.instance.id=stable-instance", "custom-dashboard", "stable-instance")]
    [InlineData(null, "service.instance.id=stable-instance", "aspire-dashboard", "stable-instance")]
    [InlineData(null, "service.name=custom-dashboard,application.secret=must-stay-local", "custom-dashboard", null)]
    public async Task OtlpExporterConfigured_ConfiguresResourceIdentity(
        string? configuredServiceName,
        string? configuredResourceAttributes,
        string expectedServiceName,
        string? expectedServiceInstanceId)
    {
        await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(
            NullLoggerFactory.Instance,
            config =>
            {
                config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1";
                config["OTEL_SERVICE_NAME"] = configuredServiceName;
                config["OTEL_RESOURCE_ATTRIBUTES"] = configuredResourceAttributes;
            });
 
        await app.StartAsync().DefaultTimeout();
 
        var resourceAttributes = app.Services.GetRequiredService<TracerProvider>().GetResource().Attributes.ToDictionary();
        Assert.Equal(expectedServiceName, resourceAttributes["service.name"]);
        if (expectedServiceInstanceId is null)
        {
            Assert.False(resourceAttributes.ContainsKey("service.instance.id"));
        }
        else
        {
            Assert.Equal(expectedServiceInstanceId, resourceAttributes["service.instance.id"]);
        }
 
        Assert.Null(app.Services.GetService<LoggerProvider>());
    }
 
    public sealed class Fixture : IAsyncLifetime
    {
        public DashboardWebApplication App { get; private set; } = null!;
        internal TestActivityExporter Exporter { get; } = new();
        internal ActivitySource TestActivitySource { get; } = new(TestActivitySourceName);
 
        public async ValueTask InitializeAsync()
        {
            App = IntegrationTestHelpers.CreateDashboardWebApplication(
                NullLoggerFactory.Instance,
                config => config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1",
                builder => builder.Services.AddOpenTelemetry()
                    .WithTracing(tracing => tracing
                        .AddSource(TestActivitySourceName)
                        .AddProcessor(new SimpleActivityExportProcessor(Exporter))));
            await App.StartAsync().DefaultTimeout();
        }
 
        public async ValueTask DisposeAsync()
        {
            await App.DisposeAsync();
            TestActivitySource.Dispose();
        }
    }
 
    internal sealed class TestActivityExporter : BaseExporter<Activity>
    {
        private readonly object _lock = new();
        private Observation? _observation;
 
        public Observation ObserveNext(ActivityTraceId traceId, Func<Activity, bool> predicate)
        {
            var observation = new Observation(this, traceId, predicate);
            lock (_lock)
            {
                Assert.Null(_observation);
                _observation = observation;
            }
            return observation;
        }
 
        public override ExportResult Export(in Batch<Activity> batch)
        {
            foreach (var activity in batch)
            {
                lock (_lock)
                {
                    if (_observation is { } observation &&
                        observation.TraceId == activity.TraceId &&
                        observation.Predicate(activity))
                    {
                        _observation = null;
                        observation.TrySetResult(activity);
                    }
                }
            }
 
            return ExportResult.Success;
        }
 
        private void Remove(Observation observation)
        {
            lock (_lock)
            {
                if (ReferenceEquals(_observation, observation))
                {
                    _observation = null;
                }
            }
        }
 
        internal sealed class Observation(TestActivityExporter exporter, ActivityTraceId traceId, Func<Activity, bool> predicate) : IDisposable
        {
            private readonly TaskCompletionSource<Activity> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
            public ActivityTraceId TraceId { get; } = traceId;
            public Func<Activity, bool> Predicate { get; } = predicate;
            public Task<Activity> Task => _completion.Task;
 
            public void Dispose() => exporter.Remove(this);
 
            public void TrySetResult(Activity activity) => _completion.TrySetResult(activity);
        }
    }
}