diff --git a/src/Aspire.Hosting/Dcp/ContainerCreator.cs b/src/Aspire.Hosting/Dcp/ContainerCreator.cs index 2d652f5ee8b..d1212a8415e 100644 --- a/src/Aspire.Hosting/Dcp/ContainerCreator.cs +++ b/src/Aspire.Hosting/Dcp/ContainerCreator.cs @@ -538,7 +538,6 @@ private string GetTunnelProxyResourceName() KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.key"), PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.pfx"), }) - .AddExecutionConfigurationGatherer(new OtlpEndpointReferenceGatherer()) .BuildAsync(_executionContext, resourceLogger, cancellationToken) .ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs b/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs deleted file mode 100644 index e6587c82ae6..00000000000 --- a/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs +++ /dev/null @@ -1,81 +0,0 @@ -// 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.Hosting.ApplicationModel; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Dcp; - -/// -/// For containers, it replaces OTLP endpoint environemnt variable value with a reference to dashboard OTLP ingestion endpoint. -/// -/// -/// In run mode, the dashboard plays the role of an OTLP collector, but the dashboard resouce is added dynamically, -/// just before the application started. That is why the OTLP configuration extension methods use configuration only. -/// OTOH, DCP has full model to work with, and can replace the OTLP endpoint environment variables with references -/// to the dashboard OTLP ingestion endpoint. For containers this allows DCP to tunnel these properly into container networks. -/// -internal class OtlpEndpointReferenceGatherer : IExecutionConfigurationGatherer -{ - public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) - { - if (!resource.IsContainer() || !resource.TryGetLastAnnotation(out var oea)) - { - // This gatherer is only relevant for container resources that emit OTEL telemetry. - return; - } - - if (!context.EnvironmentVariables.TryGetValue(KnownOtelConfigNames.ExporterOtlpEndpoint, out _)) - { - // If the OTLP endpoint is not set, do not try to set it. - return; - } - - var model = executionContext.ServiceProvider.GetService(); - if (model is null) - { - // Tests may not have a full model - return; - } - - var dashboardResource = model.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) as IResourceWithEndpoints; - if (dashboardResource == null) - { - // Most test runs do not include the dashboard, and that's ok. If the dashboard is not present, do not try to set the OTLP endpoint. - return; - } - - if (!dashboardResource.TryGetEndpoints(out var dashboardEndpoints)) - { - Debug.Fail("Dashboard does not have any endpoints??"); - return; - } - - var grpcEndpoint = dashboardEndpoints.FirstOrDefault(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName); - var httpEndpoint = dashboardEndpoints.FirstOrDefault(e => e.Name == KnownEndpointNames.OtlpHttpEndpointName); - var resourceNetwork = resource.GetDefaultResourceNetwork(); - - var endpointReference = (oea.RequiredProtocol, grpcEndpoint, httpEndpoint) switch - { - (OtlpProtocol.Grpc, not null, _) => new EndpointReference(dashboardResource, grpcEndpoint, resourceNetwork), - (OtlpProtocol.HttpProtobuf or OtlpProtocol.HttpJson, _, not null) => new EndpointReference(dashboardResource, httpEndpoint, resourceNetwork), - (_, not null, _) => new EndpointReference(dashboardResource, grpcEndpoint, resourceNetwork), - (_, _, not null) => new EndpointReference(dashboardResource, httpEndpoint, resourceNetwork), - _ => null - }; - Debug.Assert(endpointReference != null, "Dashboard should have at least one matching OTLP endpoint"); - - if (endpointReference is not null) - { - ValueProviderContext vpc = new() { ExecutionContext = executionContext, Caller = resource, Network = resourceNetwork }; - var url = await endpointReference.GetValueAsync(vpc, cancellationToken).ConfigureAwait(false); - Debug.Assert(url is not null, $"We should be able to get a URL value from the reference dashboard endpoint '{endpointReference.EndpointName}'"); - if (url is not null) - { - context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = url; - } - } - } -} diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index 72b1fc98978..9362d49ac47 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Model; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Aspire.Hosting; @@ -68,9 +69,23 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c return; } - var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol); - context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = new HostUrl(url); - context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol; + var dashboardEndpoint = ResolveOtlpEndpointFromDashboard(context, otlpExporterAnnotation.RequiredProtocol); + + if (dashboardEndpoint is not null) + { + // Use the dashboard endpoint reference directly. This resolves to the actual allocated URL, + // including when ports are randomized (e.g. isolated mode). + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = dashboardEndpoint.Value.Endpoint; + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = dashboardEndpoint.Value.Protocol; + } + else + { + // Fall back to resolving from configuration. This is the case when the dashboard resource + // is not in the model (e.g. in tests or publish mode). + var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol); + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpEndpoint] = new HostUrl(url); + context.EnvironmentVariables[KnownOtelConfigNames.ExporterOtlpProtocol] = protocol; + } // Set the service name and instance id to the resource name and UID. Values are injected by DCP. context.EnvironmentVariables[KnownOtelConfigNames.ResourceAttributes] = "service.instance.id={{- index .Annotations \"" + CustomResource.OtelServiceInstanceIdAnnotation + "\" -}}"; @@ -148,4 +163,53 @@ public static IResourceBuilder WithOtlpExporter(this IResourceBuilder b return builder; } + + /// + /// Tries to resolve the OTLP endpoint from the dashboard resource in the distributed application model. + /// This ensures that when ports are randomized (e.g. isolated mode), resources use the actual + /// allocated endpoint rather than the statically configured port. + /// + /// + /// The returned has no network context baked in, so it resolves + /// using the calling resource's network at evaluation time. This means containers automatically + /// get container-network URLs and non-containers get localhost URLs. + /// + private static (EndpointReference Endpoint, string Protocol)? ResolveOtlpEndpointFromDashboard(EnvironmentCallbackContext context, OtlpProtocol? requiredProtocol) + { + DistributedApplicationModel? model; + try + { + model = context.ExecutionContext.ServiceProvider.GetService(); + } + catch (InvalidOperationException) + { + // ServiceProvider may not be available if the container hasn't been built yet + // (e.g. env var evaluation during testing without a fully built host). + return null; + } + + if (model is null) + { + return null; + } + + var dashboardResource = model.Resources.SingleOrDefault(r => string.Equals(r.Name, KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)) as IResourceWithEndpoints; + if (dashboardResource is null) + { + return null; + } + + var grpcEndpoint = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpGrpcEndpointName); + var httpEndpoint = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpHttpEndpointName); + + return (requiredProtocol, grpcEndpoint.Exists, httpEndpoint.Exists) switch + { + (OtlpProtocol.Grpc, true, _) => (grpcEndpoint, "grpc"), + (OtlpProtocol.HttpProtobuf, _, true) => (httpEndpoint, "http/protobuf"), + (OtlpProtocol.HttpJson, _, true) => (httpEndpoint, "http/json"), + (_, true, _) => (grpcEndpoint, "grpc"), + (_, _, true) => (httpEndpoint, "http/protobuf"), + _ => null + }; + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 5b09b65778c..badf2c5b480 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -326,7 +326,8 @@ internal static async Task InstallAspireCliVersionAsync( internal static async Task AspireStartAsync( this Hex1bTerminalAutomator auto, SequenceCounter counter, - TimeSpan? startTimeout = null) + TimeSpan? startTimeout = null, + bool isolated = false) { var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(3); var expectedCounter = counter.Value; @@ -336,9 +337,11 @@ internal static async Task AspireStartAsync( ? AspireStartJsonFile : "$ASPIRE_E2E_WORKSPACE/_aspire-start.json"; + var isolatedFlag = isolated ? " --isolated" : ""; + // Keep aspire start as a single shell pipeline so tee captures the exact JSON emitted to the terminal while // pipefail preserves the real CLI exit code instead of letting tee mask build/startup failures. - await auto.TypeAsync($"(set -o pipefail; aspire start --format json | tee \"{jsonFile}\")"); + await auto.TypeAsync($"(set -o pipefail; aspire start{isolatedFlag} --format json | tee \"{jsonFile}\")"); await auto.EnterAsync(); // Wait for the command to finish — check for success or error exit. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs index b03806558e7..5e2be813d69 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs @@ -16,7 +16,15 @@ public sealed class OtelLogsTests(ITestOutputHelper output) { [Fact] [CaptureWorkspaceOnFailure] - public async Task OtelLogsReturnsStructuredLogsFromStarterApp() + public Task OtelLogsReturnsStructuredLogsFromStarterApp() + => OtelLogsReturnsStructuredLogsFromStarterAppCore(isolated: false); + + [Fact] + [CaptureWorkspaceOnFailure] + public Task OtelLogsReturnsStructuredLogsFromStarterAppIsolated() + => OtelLogsReturnsStructuredLogsFromStarterAppCore(isolated: true); + + private async Task OtelLogsReturnsStructuredLogsFromStarterAppCore(bool isolated) { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(); @@ -42,7 +50,7 @@ public async Task OtelLogsReturnsStructuredLogsFromStarterApp() await auto.WaitForSuccessPromptAsync(counter); // Start the AppHost in the background - await auto.AspireStartAsync(counter); + await auto.AspireStartAsync(counter, isolated: isolated); // Wait for the apiservice resource to be running before querying logs await auto.TypeAsync("aspire wait apiservice --status up --timeout 300"); diff --git a/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs index 534853f2e83..8c914fe7cfe 100644 --- a/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs +++ b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs @@ -1,6 +1,7 @@ // 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.Sockets; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -50,15 +51,17 @@ public async Task OtlpEndpointSet(OtlpProtocol? protocol, string? grpcEndpoint, Assert.Equal(expectedProtocol, config["OTEL_EXPORTER_OTLP_PROTOCOL"]); } - [Fact] - public async Task RequiredHttpOtlpThrowsExceptionIfNotRegistered() + [InlineData(OtlpProtocol.HttpProtobuf)] + [InlineData(OtlpProtocol.HttpJson)] + [Theory] + public async Task RequiredHttpOtlpThrowsExceptionIfNotRegistered(OtlpProtocol protocol) { using var builder = TestDistributedApplicationBuilder.Create(); builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = null; var container = builder.AddResource(new ContainerResource("testSource")) - .WithOtlpExporter(OtlpProtocol.HttpProtobuf); + .WithOtlpExporter(protocol); using var app = builder.Build(); @@ -72,25 +75,193 @@ await Assert.ThrowsAsync(() => ); } + [InlineData(default, "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "otlp-grpc", "http2", 52000, "grpc")] + [InlineData(OtlpProtocol.HttpProtobuf, "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", "otlp-http", null, 53000, "http/protobuf")] + [InlineData(OtlpProtocol.HttpJson, "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", "otlp-http", null, 53000, "http/json")] + [Theory] + public async Task OtlpEndpointResolvesFromDashboardEndpoint(OtlpProtocol? protocol, string configKey, string endpointName, string? transport, int allocatedPort, string expectedProtocol) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Configure a static OTLP endpoint URL that should be overridden by the dashboard endpoint. + builder.Configuration[configKey] = "http://localhost:18889"; + + // Add a fake dashboard resource with an OTLP endpoint. + var dashboard = builder.AddResource(new ContainerResource(KnownResourceNames.AspireDashboard)); + dashboard.Resource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: endpointName, uriScheme: "http", port: 18889, isProxied: true, transport: transport)); + + var container = builder.AddResource(new ContainerResource("testSource")); + if (protocol is { } value) + { + container = container.WithOtlpExporter(value); + } + else + { + container = container.WithOtlpExporter(); + } + + using var app = builder.Build(); + var serviceProvider = app.Services.GetRequiredService(); + + // Simulate DCP allocating a different port (e.g. isolated mode with randomized ports). + var annotation = dashboard.Resource.Annotations.OfType().Single(e => e.Name == endpointName); + annotation.AllocatedEndpoint = new AllocatedEndpoint(annotation, "localhost", allocatedPort); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout(); + + Assert.Equal($"http://localhost:{allocatedPort}", config["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal(expectedProtocol, config["OTEL_EXPORTER_OTLP_PROTOCOL"]); + } + [Fact] - public async Task RequiredHttpJsonOtlpThrowsExceptionIfNotRegistered() + public async Task OtlpEndpointPrefersGrpcWhenBothEndpointsExist() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = null; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:18889"; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:18890"; + + var dashboard = builder.AddResource(new ContainerResource(KnownResourceNames.AspireDashboard)); + dashboard.Resource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpGrpcEndpointName, uriScheme: "http", port: 18889, isProxied: true, transport: "http2")); + dashboard.Resource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpHttpEndpointName, uriScheme: "http", port: 18890, isProxied: true)); var container = builder.AddResource(new ContainerResource("testSource")) - .WithOtlpExporter(OtlpProtocol.HttpJson); + .WithOtlpExporter(); using var app = builder.Build(); + var serviceProvider = app.Services.GetRequiredService(); + + var grpcAnnotation = dashboard.Resource.Annotations.OfType().Single(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName); + grpcAnnotation.AllocatedEndpoint = new AllocatedEndpoint(grpcAnnotation, "localhost", 52000); + + var httpAnnotation = dashboard.Resource.Annotations.OfType().Single(e => e.Name == KnownEndpointNames.OtlpHttpEndpointName); + httpAnnotation.AllocatedEndpoint = new AllocatedEndpoint(httpAnnotation, "localhost", 53000); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout(); + + Assert.Equal("http://localhost:52000", config["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal("grpc", config["OTEL_EXPORTER_OTLP_PROTOCOL"]); + } + + [Fact] + public async Task OtlpEndpointFallsBackToConfigWhenNoDashboardResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:18889"; + + // No dashboard resource added - should fall back to config. + var container = builder.AddResource(new ContainerResource("testSource")) + .WithOtlpExporter(); + + using var app = builder.Build(); var serviceProvider = app.Services.GetRequiredService(); - await Assert.ThrowsAsync(() => - EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( - container.Resource, - serviceProvider: serviceProvider - ).DefaultTimeout() - ); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout(); + + Assert.Equal("http://localhost:18889", config["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal("grpc", config["OTEL_EXPORTER_OTLP_PROTOCOL"]); + } + + [Fact] + public async Task OtlpEndpointResolvesFromDashboardForExecutableResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:18889"; + + var dashboard = builder.AddResource(new ContainerResource(KnownResourceNames.AspireDashboard)); + dashboard.Resource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpGrpcEndpointName, uriScheme: "http", port: 18889, isProxied: true, transport: "http2")); + + var executable = builder.AddResource(new ExecutableResource("testExe", "test.exe", ".")) + .WithOtlpExporter(); + + using var app = builder.Build(); + var serviceProvider = app.Services.GetRequiredService(); + + // Allocate endpoint with a different port to simulate randomized ports. + var grpcAnnotation = dashboard.Resource.Annotations.OfType().Single(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName); + grpcAnnotation.AllocatedEndpoint = new AllocatedEndpoint(grpcAnnotation, "localhost", 52000); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + executable.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout(); + + Assert.Equal("http://localhost:52000", config["OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal("grpc", config["OTEL_EXPORTER_OTLP_PROTOCOL"]); + } + + [Fact] + public async Task OtlpEndpointCanBeOverriddenToPointToAnotherResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:18889"; + + // Add a dashboard resource with an OTLP endpoint. + var dashboard = builder.AddResource(new ContainerResource(KnownResourceNames.AspireDashboard)); + dashboard.Resource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpGrpcEndpointName, uriScheme: "http", port: 18889, isProxied: true, transport: "http2")); + + // Add a collector resource with its own OTLP endpoint (similar to the OTel collector sample). + var collector = builder.AddResource(new ContainerResource("otel-collector")) + .WithEndpoint(targetPort: 4317, name: "otlp-grpc", scheme: "http"); + + // Add a resource that sends telemetry via OTLP. + var container = builder.AddResource(new ContainerResource("myapp")) + .WithOtlpExporter(); + + // Override the OTLP endpoint to point to the collector, like the aspire-samples pattern: + // https://github.com/microsoft/aspire-samples/blob/main/samples/Metrics/MetricsApp.AppHost/OpenTelemetryCollector/OpenTelemetryCollectorResourceBuilderExtensions.cs + builder.Eventing.Subscribe((e, ct) => + { + var collectorEndpoint = collector.Resource.GetEndpoint("otlp-grpc"); + var appModel = e.Services.GetRequiredService(); + + foreach (var resource in appModel.Resources) + { + resource.Annotations.Add(new EnvironmentCallbackAnnotation(context => + { + if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) + { + context.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = collectorEndpoint; + } + })); + } + + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var serviceProvider = app.Services.GetRequiredService(); + var appModel = app.Services.GetRequiredService(); + + // Simulate DCP allocating endpoints. + var dashboardAnnotation = dashboard.Resource.Annotations.OfType().Single(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName); + dashboardAnnotation.AllocatedEndpoint = new AllocatedEndpoint(dashboardAnnotation, "localhost", 52000); + + var collectorAnnotation = collector.Resource.Annotations.OfType().Single(e => e.Name == "otlp-grpc"); + collectorAnnotation.AllocatedEndpoint = new AllocatedEndpoint(collectorAnnotation, "localhost", 4317); + + // Fire BeforeStartEvent so the override annotations are added. + var beforeStartEvent = new BeforeStartEvent(app.Services, appModel); + await builder.Eventing.PublishAsync(beforeStartEvent); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout(); + + // The endpoint should point to the collector, not the dashboard. + Assert.Equal("http://localhost:4317", config["OTEL_EXPORTER_OTLP_ENDPOINT"]); } }