Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/Aspire.Hosting/Dcp/ContainerCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
81 changes: 0 additions & 81 deletions src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs

This file was deleted.

70 changes: 67 additions & 3 deletions src/Aspire.Hosting/OtlpConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the otlp collector? Does this break that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a test. No it doesn't break it.


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 + "\" -}}";
Expand Down Expand Up @@ -148,4 +163,53 @@ public static IResourceBuilder<T> WithOtlpExporter<T>(this IResourceBuilder<T> b

return builder;
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The returned <see cref="EndpointReference"/> 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.
/// </remarks>
private static (EndpointReference Endpoint, string Protocol)? ResolveOtlpEndpointFromDashboard(EnvironmentCallbackContext context, OtlpProtocol? requiredProtocol)
{
DistributedApplicationModel? model;
try
{
model = context.ExecutionContext.ServiceProvider.GetService<DistributedApplicationModel>();
}
catch (InvalidOperationException)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at ExecutionContext.ServiceProvider property. It throws if not in a completed state.

{
// 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why this works and makes me happy is that GetEndpoint() returns an EndpointReference that is not tied to any particular network. That reference is subsequently resolved in the context of the network that the resource using the dashboard is connected to.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's clean.

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
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions tests/Aspire.Cli.EndToEnd.Tests/OtelLogsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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");
Expand Down
Loading
Loading