// 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;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting.Tests;
[Trait("Partition", "3")]
public class WithOtlpExporterTests
{
[InlineData(default, "http://localhost:8889", null, "http://localhost:8889", "grpc")]
[InlineData(default, "http://localhost:8889", "http://localhost:8890", "http://localhost:8889", "grpc")]
[InlineData(default, null, "http://localhost:8890", "http://localhost:8890", "http/protobuf")]
[InlineData(OtlpProtocol.HttpProtobuf, "http://localhost:8889", "http://localhost:8890", "http://localhost:8890", "http/protobuf")]
[InlineData(OtlpProtocol.Grpc, "http://localhost:8889", "http://localhost:8890", "http://localhost:8889", "grpc")]
[InlineData(OtlpProtocol.Grpc, null, null, "http://localhost:18889", "grpc")]
[InlineData(OtlpProtocol.HttpJson, "http://localhost:8889", "http://localhost:8890", "http://localhost:8890", "http/json")]
[Theory]
public async Task OtlpEndpointSet(OtlpProtocol? protocol, string? grpcEndpoint, string? httpEndpoint, string expectedUrl, string expectedProtocol)
{
using var builder = TestDistributedApplicationBuilder.Create();
builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = grpcEndpoint;
builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = httpEndpoint;
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<IServiceProvider>();
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: serviceProvider
).DefaultTimeout();
Assert.Equal(expectedUrl, config["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal(expectedProtocol, config["OTEL_EXPORTER_OTLP_PROTOCOL"]);
}
[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(protocol);
using var app = builder.Build();
var serviceProvider = app.Services.GetRequiredService<IServiceProvider>();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: serviceProvider
).DefaultTimeout()
);
}
[Fact]
public async Task OptionalHttpOtlpSkipsEnvironmentWhenEndpointIsUnavailable()
{
using var builder = TestDistributedApplicationBuilder.Create(options => options.DisableDashboard = true);
var endpointInjected = false;
var container = builder.AddResource(new ContainerResource("testSource"))
.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf)
.WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true")
.WithEnvironment(context =>
endpointInjected = context.EnvironmentVariables.ContainsKey(KnownOtelConfigNames.ExporterOtlpEndpoint));
using var app = builder.Build();
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: app.Services).DefaultTimeout();
Assert.Empty(config);
Assert.False(endpointInjected);
var annotation = Assert.Single(container.Resource.Annotations.OfType<OtlpExporterAnnotation>());
Assert.Equal(OtlpProtocol.HttpProtobuf, annotation.RequiredProtocol);
}
[Fact]
public async Task OptionalHttpOtlpAppliesActivationWithoutCallbackOrdering()
{
using var builder = TestDistributedApplicationBuilder.Create(options => options.DisableDashboard = true);
builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318";
var container = builder.AddResource(new ContainerResource("testSource"))
.WithEnvironment("CALLBACK_RAN", "true")
.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf)
.WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true");
using var app = builder.Build();
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: app.Services).DefaultTimeout();
Assert.Equal("http://localhost:4318", config["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal("http/protobuf", config["OTEL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal("true", config["OTEL_DENO"]);
Assert.Equal("true", config["CALLBACK_RAN"]);
}
[Fact]
public async Task LastOtlpExporterAnnotationControlsProtocolAndDoesNotApplySupersededActivation()
{
using var builder = TestDistributedApplicationBuilder.Create(options => options.DisableDashboard = true);
builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:4317";
builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = null;
var container = builder.AddResource(new ContainerResource("testSource"))
.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpProtobuf)
.WithOtlpExporterActivationEnvironmentVariable("OTEL_DENO", "true")
.WithOtlpExporter();
using var app = builder.Build();
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: app.Services).DefaultTimeout();
Assert.Equal("http://localhost:4317", config["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal("grpc", config["OTEL_EXPORTER_OTLP_PROTOCOL"]);
Assert.False(config.ContainsKey("OTEL_DENO"));
}
[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<IServiceProvider>();
// Simulate DCP allocating a different port (e.g. isolated mode with randomized ports).
var annotation = dashboard.Resource.Annotations.OfType<EndpointAnnotation>().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 OtlpEndpointPrefersGrpcWhenBothEndpointsExist()
{
using var builder = TestDistributedApplicationBuilder.Create();
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();
using var app = builder.Build();
var serviceProvider = app.Services.GetRequiredService<IServiceProvider>();
var grpcAnnotation = dashboard.Resource.Annotations.OfType<EndpointAnnotation>().Single(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName);
grpcAnnotation.AllocatedEndpoint = new AllocatedEndpoint(grpcAnnotation, "localhost", 52000);
var httpAnnotation = dashboard.Resource.Annotations.OfType<EndpointAnnotation>().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<IServiceProvider>();
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<IServiceProvider>();
// Allocate endpoint with a different port to simulate randomized ports.
var grpcAnnotation = dashboard.Resource.Annotations.OfType<EndpointAnnotation>().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<BeforeStartEvent>((e, ct) =>
{
var collectorEndpoint = collector.Resource.GetEndpoint("otlp-grpc");
var appModel = e.Services.GetRequiredService<DistributedApplicationModel>();
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<IServiceProvider>();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Simulate DCP allocating endpoints.
var dashboardAnnotation = dashboard.Resource.Annotations.OfType<EndpointAnnotation>().Single(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName);
dashboardAnnotation.AllocatedEndpoint = new AllocatedEndpoint(dashboardAnnotation, "localhost", 52000);
var collectorAnnotation = collector.Resource.Annotations.OfType<EndpointAnnotation>().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"]);
}
}