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
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ namespace Aspire.Hosting.ApplicationModel;
[DebuggerDisplay("Type = {GetType().Name,nq}")]
public class OtlpExporterAnnotation : IResourceAnnotation
{
}
/// <summary>
/// Gets or sets the default protocol for the OTLP exporter.
/// </summary>
public OtlpProtocol? RequiredProtocol { get; init; }
}
102 changes: 85 additions & 17 deletions src/Aspire.Hosting/OtlpConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// Configures OpenTelemetry in projects using environment variables.
/// </summary>
/// <param name="resource">The resource to add annotations to.</param>
/// <param name="configuration">The configuration to use for the OTLP exporter endpoint URL.</param>
/// <param name="environment">The host environment to check if the application is running in development mode.</param>
/// <param name="protocol">The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http.</param>
public static void AddOtlpEnvironment(IResource resource, IConfiguration configuration, IHostEnvironment environment, OtlpProtocol protocol)
Copy link
Member

Choose a reason for hiding this comment

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

The existing AddOtlpEnvironment overload should probably call this one to reduce code duplication.

Copy link
Member Author

Choose a reason for hiding this comment

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

The protocol parameter is non nullable here and so the RequiredProtocol is set to it, while if nothing is passed the RequiredProtocol will be null.

I did it this way since I don't think I can add a optional nullable parameter to the existing signature, to it was the simplest I could make it without having a third private method that took a nullable protocol which didn't seem to add much value here.

{
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

Expand All @@ -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<OtlpExporterAnnotation>(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<IDcpDependencyCheckService>();
var dcpInfo = await dcpDependencyCheckService.GetDcpInfoAsync(cancellationToken: context.CancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -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<string, object> environmentVariables, string url, string protocol)
{
environmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = new HostUrl(url);
Expand All @@ -117,4 +164,25 @@ public static IResourceBuilder<T> WithOtlpExporter<T>(this IResourceBuilder<T> b

return builder;
}

/// <summary>
/// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard.
/// <list type="number">
/// <item>It sets the OTLP endpoint to the value of the <c>ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL</c> environment variable.</item>
/// <item>It sets the service name and instance id to the resource name and UID. Values are injected by the orchestrator.</item>
/// <item>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.</item>
/// </list>
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="protocol">The protocol to use for the OTLP exporter. If not set, it will try gRPC then Http.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<T> WithOtlpExporter<T>(this IResourceBuilder<T> builder, OtlpProtocol protocol) where T : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);

AddOtlpEnvironment(builder.Resource, builder.ApplicationBuilder.Configuration, builder.ApplicationBuilder.Environment, protocol);

return builder;
}
}
20 changes: 20 additions & 0 deletions src/Aspire.Hosting/OtlpProtocol.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Protocols available for OTLP exporters.
/// </summary>
public enum OtlpProtocol
{
/// <summary>
/// A gRPC-based OTLP exporter.
/// </summary>
Grpc,

/// <summary>
/// Http/Protobuf-based OTLP exporter.
/// </summary>
HttpProtobuf
}
72 changes: 72 additions & 0 deletions tests/Aspire.Hosting.Tests/WithOtlpExporterTests.cs
Original file line number Diff line number Diff line change
@@ -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<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"]);
}

[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<IServiceProvider>();

await Assert.ThrowsAsync<InvalidOperationException>(() =>
EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
container.Resource,
serviceProvider: serviceProvider
).DefaultTimeout()
);
}
}
Loading