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"]);
}
}