diff --git a/src/Aspire.Hosting/ApplicationModel/OtlpExporterAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/OtlpExporterAnnotation.cs index c2698c7f188..abe67a95231 100644 --- a/src/Aspire.Hosting/ApplicationModel/OtlpExporterAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/OtlpExporterAnnotation.cs @@ -11,4 +11,8 @@ namespace Aspire.Hosting.ApplicationModel; [DebuggerDisplay("Type = {GetType().Name,nq}")] public class OtlpExporterAnnotation : IResourceAnnotation { -} \ No newline at end of file + /// + /// Gets or sets the default protocol for the OTLP exporter. + /// + public OtlpProtocol? RequiredProtocol { get; init; } +} diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index 024cf9407fb..1c3be620536 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -32,6 +32,30 @@ public static void AddOtlpEnvironment(IResource resource, IConfiguration configu // Add annotation to mark this resource as having OTLP exporter configured resource.Annotations.Add(new OtlpExporterAnnotation()); + RegisterOtlpEnvironment(resource, configuration, environment); + } + + /// + /// Configures OpenTelemetry in projects using environment variables. + /// + /// The resource to add annotations to. + /// The configuration to use for the OTLP exporter endpoint URL. + /// The host environment to check if the application is running in development mode. + /// The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http. + public static void AddOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment, OtlpProtocol protocol) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(environment); + + // Add annotation to mark this resource as having OTLP exporter configured with a required protocol + resource.Annotations.Add(new OtlpExporterAnnotation { RequiredProtocol = protocol }); + + RegisterOtlpEnvironment(resource, configuration, environment); + } + + private static void RegisterOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment) + { // Configure OpenTelemetry in projects using environment variables. // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/configuration/sdk-environment-variables.md @@ -43,26 +67,13 @@ public static void AddOtlpEnvironment(IResource resource, IConfiguration configu return; } - var dashboardOtlpGrpcUrl = configuration.GetString(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); - var dashboardOtlpHttpUrl = configuration.GetString(KnownConfigNames.DashboardOtlpHttpEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); - - // The dashboard can support OTLP/gRPC and OTLP/HTTP endpoints at the same time, but it can - // only tell resources about one of the endpoints via environment variables. - // If both OTLP/gRPC and OTLP/HTTP are available then prefer gRPC. - if (dashboardOtlpGrpcUrl != null) - { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpGrpcUrl, "grpc"); - } - else if (dashboardOtlpHttpUrl != null) + if (!resource.TryGetLastAnnotation(out var otlpExporterAnnotation)) { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpHttpUrl, "http/protobuf"); - } - else - { - // No endpoints provided to host. Use default value for URL. - SetOtelEndpointAndProtocol(context.EnvironmentVariables, DashboardOtlpUrlDefaultValue, "grpc"); + return; } + SetOtel(context, configuration, otlpExporterAnnotation.RequiredProtocol); + // Set the service name and instance id to the resource name and UID. Values are injected by DCP. var dcpDependencyCheckService = context.ExecutionContext.ServiceProvider.GetRequiredService(); var dcpInfo = await dcpDependencyCheckService.GetDcpInfoAsync(cancellationToken: context.CancellationToken).ConfigureAwait(false); @@ -91,6 +102,42 @@ public static void AddOtlpEnvironment(IResource resource, IConfiguration configu } })); + static void SetOtel(EnvironmentCallbackContext context, IConfiguration configuration, OtlpProtocol? requiredProtocol) + { + var dashboardOtlpGrpcUrl = configuration.GetString(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); + var dashboardOtlpHttpUrl = configuration.GetString(KnownConfigNames.DashboardOtlpHttpEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); + + // Check if a specific protocol is required by the annotation + if (requiredProtocol is OtlpProtocol.Grpc) + { + SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpGrpcUrl ?? DashboardOtlpUrlDefaultValue, "grpc"); + } + else if (requiredProtocol is OtlpProtocol.HttpProtobuf) + { + SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpHttpUrl ?? throw new InvalidOperationException("OtlpExporter is configured to require http/protobuf, but no endpoint was configured for ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"), "http/protobuf"); + } + else + { + // No specific protocol required, use the existing preference logic + // The dashboard can support OTLP/gRPC and OTLP/HTTP endpoints at the same time, but it can + // only tell resources about one of the endpoints via environment variables. + // If both OTLP/gRPC and OTLP/HTTP are available then prefer gRPC. + if (dashboardOtlpGrpcUrl is not null) + { + SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpGrpcUrl, "grpc"); + } + else if (dashboardOtlpHttpUrl is not null) + { + SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpHttpUrl, "http/protobuf"); + } + else + { + // No endpoints provided to host. Use default value for URL. + SetOtelEndpointAndProtocol(context.EnvironmentVariables, DashboardOtlpUrlDefaultValue, "grpc"); + } + } + } + static void SetOtelEndpointAndProtocol(Dictionary environmentVariables, string url, string protocol) { environmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = new HostUrl(url); @@ -117,4 +164,25 @@ public static IResourceBuilder WithOtlpExporter(this IResourceBuilder b return builder; } + + /// + /// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard. + /// + /// It sets the OTLP endpoint to the value of the ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL environment variable. + /// It sets the service name and instance id to the resource name and UID. Values are injected by the orchestrator. + /// It sets a small batch schedule delay in development. This reduces the delay that OTLP exporter waits to sends telemetry and makes the dashboard telemetry pages responsive. + /// + /// + /// The resource type. + /// The resource builder. + /// The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http. + /// The . + public static IResourceBuilder WithOtlpExporter(this IResourceBuilder builder, OtlpProtocol protocol) where T : IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + + AddOtlpEnvironment(builder.Resource, builder.ApplicationBuilder.Configuration, builder.ApplicationBuilder.Environment, protocol); + + return builder; + } } diff --git a/src/Aspire.Hosting/OtlpProtocol.cs b/src/Aspire.Hosting/OtlpProtocol.cs new file mode 100644 index 00000000000..1f02e7b7251 --- /dev/null +++ b/src/Aspire.Hosting/OtlpProtocol.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// Protocols available for OTLP exporters. +/// +public enum OtlpProtocol +{ + /// + /// A gRPC-based OTLP exporter. + /// + Grpc, + + /// + /// Http/Protobuf-based OTLP exporter. + /// + HttpProtobuf +} diff --git a/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs new file mode 100644 index 00000000000..f6a966e0099 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +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")] + [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(); + + 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"]); + } + + [Fact] + public async Task RequiredHttpOtlpThrowsExceptionIfNotRegistered() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = null; + + var container = builder.AddResource(new ContainerResource("testSource")) + .WithOtlpExporter(OtlpProtocol.HttpProtobuf); + + using var app = builder.Build(); + + var serviceProvider = app.Services.GetRequiredService(); + + await Assert.ThrowsAsync(() => + EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container.Resource, + serviceProvider: serviceProvider + ).DefaultTimeout() + ); + } +}