diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index b881ae643..189c03269 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -42,6 +42,7 @@ jobs:
Hosting.Ngrok.Tests,
Hosting.NodeJS.Extensions.Tests,
Hosting.Ollama.Tests,
+ Hosting.OpenTelemetryCollector.Tests,
Hosting.PapercutSmtp.Tests,
Hosting.PostgreSQL.Extensions.Tests,
Hosting.PowerShell.Tests,
diff --git a/CODEOWNERS b/CODEOWNERS
index 9341fe0ef..5c9b621d8 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -125,3 +125,9 @@
/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/ @Odonno
/src/CommunityToolkit.Aspire.SurrealDb/ @Odonno
/tests/CommunityToolkit.Aspire.SurrealDb.Tests/ @Odonno
+
+# CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector
+
+/examples/opentelemetry-collector/ @martinjt
+/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/ @martinjt
+/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ @martinjt
diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a1c768323..e262de04e 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -105,6 +105,11 @@
+
+
+
+
+
@@ -172,6 +177,7 @@
+
@@ -221,6 +227,7 @@
+
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.csproj b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.csproj
new file mode 100644
index 000000000..632eb29ed
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.csproj
@@ -0,0 +1,12 @@
+
+
+
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.http b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.http
new file mode 100644
index 000000000..33c255423
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.http
@@ -0,0 +1,6 @@
+@CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api_HostAddress = http://localhost:5121
+
+GET {{CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Program.cs b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Program.cs
new file mode 100644
index 000000000..d9c424076
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Program.cs
@@ -0,0 +1,31 @@
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+var app = builder.Build();
+
+var summaries = new[]
+{
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+};
+
+app.MapGet("/weatherforecast", () =>
+{
+ var forecast = Enumerable.Range(1, 5).Select(index =>
+ new WeatherForecast
+ (
+ DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ Random.Shared.Next(-20, 55),
+ summaries[Random.Shared.Next(summaries.Length)]
+ ))
+ .ToArray();
+ return forecast;
+})
+.WithName("GetWeatherForecast");
+
+app.Run();
+
+record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+{
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+}
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Properties/launchSettings.json b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..1180e5470
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5121",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7262;http://localhost:5121",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/appsettings.json b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/AppHost.cs b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/AppHost.cs
new file mode 100644
index 000000000..f640126b5
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/AppHost.cs
@@ -0,0 +1,9 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddProject("api");
+
+builder.AddOpenTelemetryCollector("opentelemetry-collector")
+ .WithAppForwarding()
+ .WithConfig("./config.yaml");
+
+builder.Build().Run();
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.csproj b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.csproj
new file mode 100644
index 000000000..71d3ec06f
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Exe
+ enable
+ enable
+ f0af42ab-ea83-435c-9273-89269ca78d75
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/Properties/launchSettings.json b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..a8952256e
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17224;http://localhost:15263",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21066",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22110"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15263",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19260",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20086"
+ }
+ }
+ }
+}
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/appsettings.json b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/appsettings.json
new file mode 100644
index 000000000..31c092aa4
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/config.yaml b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/config.yaml
new file mode 100644
index 000000000..9cff10fec
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/config.yaml
@@ -0,0 +1,43 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+processors:
+ batch:
+ transform/add-collector-enabled:
+ error_mode: ignore
+ log_statements:
+ - set(resource.attributes["collector.enabled"], "true")
+ trace_statements:
+ - set(resource.attributes["collector.enabled"], "true")
+ metric_statements:
+ - set(resource.attributes["collector.enabled"], "true")
+
+exporters:
+ debug:
+ verbosity: detailed
+ otlp/aspire:
+ endpoint: ${env:ASPIRE_ENDPOINT}
+ headers:
+ x-otlp-api-key: ${env:ASPIRE_API_KEY}
+ tls:
+ insecure_skip_verify: true
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch, transform/add-collector-enabled]
+ exporters: [otlp/aspire]
+ metrics:
+ receivers: [otlp]
+ processors: [batch, transform/add-collector-enabled]
+ exporters: [otlp/aspire]
+ logs:
+ receivers: [otlp]
+ processors: [batch, transform/add-collector-enabled]
+ exporters: [debug,otlp/aspire]
\ No newline at end of file
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults.csproj b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults.csproj
new file mode 100644
index 000000000..caa6344dc
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults.csproj
@@ -0,0 +1,21 @@
+
+
+
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/Extensions.cs b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/Extensions.cs
new file mode 100644
index 000000000..1390809c7
--- /dev/null
+++ b/examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/Extensions.cs
@@ -0,0 +1,126 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj
new file mode 100644
index 000000000..8ae41a461
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj
@@ -0,0 +1,16 @@
+
+
+
+ An Aspire component to add an OpenTelemetry Collector into the OTLP pipeline
+ opentelemetry observability
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs
new file mode 100644
index 000000000..ecdcdd62e
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs
@@ -0,0 +1,178 @@
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods to add the collector resource
+///
+public static class OpenTelemetryCollectorExtensions
+{
+ private const string DashboardOtlpUrlVariableNameLegacy = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
+ private const string DashboardOtlpUrlVariableName = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL";
+ private const string DashboardOtlpApiKeyVariableName = "AppHost:OtlpApiKey";
+ private const string DashboardOtlpUrlDefaultValue = "http://localhost:18889";
+
+ ///
+ /// Adds an OpenTelemetry Collector into the Aspire AppHost
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static IResourceBuilder AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder,
+ string name,
+ Action? configureSettings = null)
+ {
+ var url = builder.Configuration[DashboardOtlpUrlVariableName] ??
+ builder.Configuration[DashboardOtlpUrlVariableNameLegacy] ??
+ DashboardOtlpUrlDefaultValue;
+
+ var settings = new OpenTelemetryCollectorSettings();
+ configureSettings?.Invoke(settings);
+
+ var isHttpsEnabled = !settings.ForceNonSecureReceiver && url.StartsWith("https", StringComparison.OrdinalIgnoreCase);
+
+ var dashboardOtlpEndpoint = ReplaceLocalhostWithContainerHost(url, builder.Configuration);
+
+ var resource = new OpenTelemetryCollectorResource(name);
+ var resourceBuilder = builder.AddResource(resource)
+ .WithImage(settings.CollectorImage, settings.CollectorTag)
+ .WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint)
+ .WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]);
+
+ if (settings.EnableGrpcEndpoint)
+ resourceBuilder.WithEndpoint(targetPort: 4317, name: OpenTelemetryCollectorResource.GrpcEndpointName, scheme: isHttpsEnabled ? "https" : "http");
+ if (settings.EnableHttpEndpoint)
+ resourceBuilder.WithEndpoint(targetPort: 4318, name: OpenTelemetryCollectorResource.HttpEndpointName, scheme: isHttpsEnabled ? "https" : "http");
+
+
+ if (!settings.ForceNonSecureReceiver && isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment())
+ {
+ resourceBuilder.RunWithHttpsDevCertificate();
+ var certFilePath = Path.Combine(DevCertHostingExtensions.DEV_CERT_BIND_MOUNT_DEST_DIR, DevCertHostingExtensions.CERT_FILE_NAME);
+ var certKeyPath = Path.Combine(DevCertHostingExtensions.DEV_CERT_BIND_MOUNT_DEST_DIR, DevCertHostingExtensions.CERT_KEY_FILE_NAME);
+ if (settings.EnableHttpEndpoint)
+ {
+ resourceBuilder.WithArgs(
+ $@"--config=yaml:receivers::otlp::protocols::http::tls::cert_file: ""{certFilePath}""",
+ $@"--config=yaml:receivers::otlp::protocols::http::tls::key_file: ""{certKeyPath}""");
+ }
+ if (settings.EnableGrpcEndpoint)
+ {
+ resourceBuilder.WithArgs(
+ $@"--config=yaml:receivers::otlp::protocols::grpc::tls::cert_file: ""{certFilePath}""",
+ $@"--config=yaml:receivers::otlp::protocols::grpc::tls::key_file: ""{certKeyPath}""");
+ }
+ }
+ return resourceBuilder;
+ }
+
+ ///
+ /// Force all apps to forward to the collector instead of the dashboard directly
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithAppForwarding(this IResourceBuilder builder)
+ {
+ builder.AddEnvironmentVariablesEventHook()
+ .WithFirstStartup();
+
+ return builder;
+ }
+
+ private static string ReplaceLocalhostWithContainerHost(string value, IConfiguration configuration)
+ {
+ var hostName = configuration["AppHost:ContainerHostname"] ?? "host.docker.internal";
+
+ return value.Replace("localhost", hostName, StringComparison.OrdinalIgnoreCase)
+ .Replace("127.0.0.1", hostName)
+ .Replace("[::1]", hostName);
+ }
+
+ ///
+ /// Adds a config file to the collector
+ ///
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithConfig(this IResourceBuilder builder, string configPath)
+ {
+ var configFileInfo = new FileInfo(configPath);
+ return builder.WithBindMount(configPath, $"/config/{configFileInfo.Name}")
+ .WithArgs($"--config=/config/{configFileInfo.Name}");
+ }
+
+ ///
+ /// Sets up the OnBeforeResourceStarted event to add a wait annotation to all resources that have the OtlpExporterAnnotation
+ ///
+ ///
+ ///
+ private static IResourceBuilder WithFirstStartup(this IResourceBuilder builder)
+ {
+ builder.OnBeforeResourceStarted((collectorResource, beforeStartedEvent, cancellationToken) =>
+ {
+ var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(collectorResource);
+ var appModel = beforeStartedEvent.Services.GetRequiredService();
+ var resources = appModel.GetProjectResources();
+
+ foreach (var resourceItem in resources.Where(r => r.HasAnnotationOfType()))
+ {
+ resourceItem.Annotations.Add(new WaitAnnotation(collectorResource, WaitType.WaitUntilHealthy));
+ }
+ return Task.CompletedTask;
+ });
+ return builder;
+ }
+
+ ///
+ /// Sets up the OnResourceEndpointsAllocated event to add/update the OTLP environment variables for the collector to the various resources
+ ///
+ ///
+ private static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder)
+ {
+ builder.OnResourceEndpointsAllocated((collectorResource, allocatedEvent, cancellationToken) =>
+ {
+ var logger = allocatedEvent.Services.GetRequiredService().GetLogger(collectorResource);
+ var appModel = allocatedEvent.Services.GetRequiredService();
+ var resources = appModel.GetProjectResources();
+
+ var grpcEndpoint = collectorResource.GetEndpoint(collectorResource.GrpcEndpoint.EndpointName);
+ var httpEndpoint = collectorResource.GetEndpoint(collectorResource.HttpEndpoint.EndpointName);
+
+ if (!resources.Any())
+ {
+ logger.LogInformation("No resources to add Environment Variables to");
+ }
+
+ foreach (var resourceItem in resources.Where(r => r.HasAnnotationOfType()))
+ {
+ logger.LogDebug("Forwarding Telemetry for {name} to the collector", resourceItem.Name);
+ if (resourceItem is null) continue;
+
+ resourceItem.Annotations.Add(new EnvironmentCallbackAnnotation(context =>
+ {
+ var protocol = context.EnvironmentVariables.GetValueOrDefault("OTEL_EXPORTER_OTLP_PROTOCOL", "");
+ var endpoint = protocol.ToString() == "http/protobuf" ? httpEndpoint : grpcEndpoint;
+
+ if (endpoint is null)
+ {
+ logger.LogWarning("No {protocol} endpoint on the collector for {resourceName} to use",
+ protocol, resourceItem.Name);
+ return;
+ }
+
+ context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT");
+ context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url);
+ }));
+ }
+
+ return Task.CompletedTask;
+ });
+
+ return builder;
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs
new file mode 100644
index 000000000..c6aa90c23
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs
@@ -0,0 +1,23 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting;
+
+///
+/// The collector resource
+///
+/// Name of the resource
+public class OpenTelemetryCollectorResource(string name) : ContainerResource(name)
+{
+ internal static string GrpcEndpointName = "grpc";
+ internal static string HttpEndpointName = "http";
+
+ ///
+ /// gRPC Endpoint
+ ///
+ public EndpointReference GrpcEndpoint => new(this, GrpcEndpointName);
+
+ ///
+ /// HTTP Endpoint
+ ///
+ public EndpointReference HttpEndpoint => new(this, HttpEndpointName);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorRoutingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorRoutingExtensions.cs
new file mode 100644
index 000000000..f0cd961df
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorRoutingExtensions.cs
@@ -0,0 +1,35 @@
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting;
+
+///
+/// Hooks to add the OTLP environment variables to the various resources
+///
+public static class OpenTelemetryCollectorRoutingExtensions
+{
+ ///
+ /// Resource the telemetry for the resource through the specified OpenTelemetry Collector
+ ///
+ ///
+ ///
+ ///
+ public static IResourceBuilder WithOpenTelemetryCollectorRouting(this IResourceBuilder builder, IResourceBuilder collectorBuilder) where T : IResourceWithEnvironment
+ {
+ builder.WithEnvironment(callback =>
+ {
+ var otlpProtocol = callback.EnvironmentVariables.GetValueOrDefault("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc");
+ var endpoint = collectorBuilder.Resource.GetEndpoint(otlpProtocol.ToString() ?? "grpc");
+ callback.Logger.LogDebug("Forwarding Telemetry for {name} to the collector on {endpoint}", builder.Resource.Name, endpoint.Url);
+
+ if (!callback.EnvironmentVariables.TryAdd("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint))
+ {
+ callback.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint;
+ }
+ });
+ builder.WithAnnotation(new WaitAnnotation(collectorBuilder.Resource, WaitType.WaitUntilHealthy));
+
+ return builder;
+ }
+
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs
new file mode 100644
index 000000000..ddf8c79dc
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs
@@ -0,0 +1,46 @@
+namespace Aspire.Hosting;
+
+///
+/// Settings for the OpenTelemetry Collector
+///
+public class OpenTelemetryCollectorSettings
+{
+ ///
+ /// The Tag to use for the collector
+ ///
+ public string CollectorTag { get; set; } = "latest";
+
+ ///
+ /// The registry for the image
+ ///
+ public string Registry { get; set; } = "ghcr.io";
+
+ ///
+ /// The collector image path
+ ///
+ public string Image { get; set; } = "open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib";
+
+ ///
+ /// The image of the collector, defaults to ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib
+ ///
+ public string CollectorImage { get => $"{Registry}/{Image}"; }
+
+ ///
+ /// Force the default OTLP receivers in the collector to use HTTP even if Aspire is set to HTTPS
+ ///
+ public bool ForceNonSecureReceiver { get; set; } = false;
+
+ ///
+ /// Enable the gRPC endpoint on the collector container (requires the relevant collector config)
+ ///
+ /// Note: this will also setup SSL if Aspire is configured for HTTPS
+ ///
+ public bool EnableGrpcEndpoint { get; set; } = true;
+
+ ///
+ /// Enable the HTTP endpoint on the collector container (requires the relevant collector config)
+ ///
+ /// Note: this will also setup SSL if Aspire is configured for HTTPS
+ ///
+ public bool EnableHttpEndpoint { get; set; } = true;
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/README.md b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/README.md
new file mode 100644
index 000000000..f7d511638
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/README.md
@@ -0,0 +1,41 @@
+# CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector
+
+## Overview
+
+This .NET Aspire Integration can be used to include [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) in a container.
+
+## Usage
+
+### Example 1: Add OpenTelemetry Collector without automatic redirection
+
+In this approach, only the projects and resource that you forward the collector to
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+
+var collector = builder.AddOpenTelemetryCollector("opentelemetry-collector")
+ .WithConfig("./config.yaml");
+
+builder.AddProject("api")
+ .WithOpenTelemetryCollectorRouting(collector);
+
+builder.Build().Run();
+```
+
+### Example 2: Add OpenTelemetry Collector with automatic redirection
+
+In this approach, all projects and resources that have the `OtlpExporterAnnotation` will have their telemetry forwarded to the collector.
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+
+var collector = builder.AddOpenTelemetryCollector("opentelemetry-collector")
+ .WithConfig("./config.yaml")
+ .WithAppForwarding();
+
+builder.AddProject("api");
+
+builder.Build().Run();
+```
diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs
new file mode 100644
index 000000000..f62539822
--- /dev/null
+++ b/src/Shared/DevCertHostingExtensions.cs
@@ -0,0 +1,151 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting;
+
+///
+/// Extensions for adding Dev Certs to aspire resources.
+///
+public static class DevCertHostingExtensions
+{
+ ///
+ /// The destination directory for the certificate files in a container.
+ ///
+ public const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs";
+
+ ///
+ /// The file name of the certificate file.
+ ///
+ public const string CERT_FILE_NAME = "dev-cert.pem";
+
+ ///
+ /// The file name of the certificate key file.
+ ///
+ public const string CERT_KEY_FILE_NAME = "dev-cert.key";
+
+ ///
+ /// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
+ /// .ApplicationBuilder.ExecutionContext.IsRunMode == true.
+ /// If the resource is a , the certificate files will be provided via WithContainerFiles.
+ ///
+ ///
+ /// This method does not configure an HTTPS endpoint on the resource.
+ /// Use to configure an HTTPS endpoint.
+ ///
+ public static IResourceBuilder RunWithHttpsDevCertificate(
+ this IResourceBuilder builder, string certFileEnv = "", string certKeyFileEnv = "")
+ where TResource : IResourceWithEnvironment, IResourceWithWaitSupport
+ {
+ if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
+ {
+ return builder;
+ }
+
+ if (builder.Resource is not ContainerResource &&
+ (!string.IsNullOrEmpty(certFileEnv) || !string.IsNullOrEmpty(certKeyFileEnv)))
+ {
+ throw new InvalidOperationException("RunWithHttpsDevCertificate needs environment variables only for Resources that aren't Containers.");
+ }
+
+ // Create temp directory for certificate export
+ var tempDir = Directory.CreateTempSubdirectory("aspire-dev-certs");
+ var certExportPath = Path.Combine(tempDir.FullName, "dev-cert.pem");
+ var certKeyExportPath = Path.Combine(tempDir.FullName, "dev-cert.key");
+
+ // Create a unique resource name for the certificate export
+ var exportResourceName = $"dev-cert-export";
+
+ // Check if we already have a certificate export resource
+ var existingResource = builder.ApplicationBuilder.Resources.FirstOrDefault(r => r.Name == exportResourceName);
+ IResourceBuilder exportExecutable;
+
+ if (existingResource is null)
+ {
+ // Create the executable resource to export the certificate
+ exportExecutable = builder.ApplicationBuilder
+ .AddExecutable(exportResourceName, "dotnet", tempDir.FullName)
+ .WithEnvironment("DOTNET_CLI_UI_LANGUAGE", "en") // Ensure consistent output language
+ .WithArgs(context =>
+ {
+ context.Args.Add("dev-certs");
+ context.Args.Add("https");
+ context.Args.Add("--export-path");
+ context.Args.Add(certExportPath);
+ context.Args.Add("--format");
+ context.Args.Add("Pem");
+ context.Args.Add("--no-password");
+ });
+ }
+ else
+ {
+ exportExecutable = builder.ApplicationBuilder.CreateResourceBuilder((ExecutableResource)existingResource);
+ }
+
+ builder.WaitForCompletion(exportExecutable);
+
+ // Configure the current resource with the certificate paths
+ if (builder.Resource is ContainerResource containerResource)
+ {
+ var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{CERT_FILE_NAME}";
+ var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{CERT_KEY_FILE_NAME}";
+
+ if (!containerResource.TryGetContainerMounts(out var mounts) &&
+ mounts is not null &&
+ mounts.Any(cm => cm.Target == DEV_CERT_BIND_MOUNT_DEST_DIR))
+ {
+ return builder;
+ }
+
+ // Use WithContainerFiles to provide the certificate files to the container
+ builder.ApplicationBuilder.CreateResourceBuilder(containerResource)
+ .WithContainerFiles(DEV_CERT_BIND_MOUNT_DEST_DIR, (context, cancellationToken) =>
+ {
+ var files = new List();
+
+ // Check if certificate files exist before adding them
+ if (File.Exists(certExportPath))
+ {
+ files.Add(new ContainerFile
+ {
+ Name = CERT_FILE_NAME,
+ SourcePath = certExportPath
+ });
+ }
+
+ if (File.Exists(certKeyExportPath))
+ {
+ files.Add(new ContainerFile
+ {
+ Name = CERT_KEY_FILE_NAME,
+ SourcePath = certKeyExportPath
+ });
+ }
+
+ return Task.FromResult(files.AsEnumerable());
+ });
+
+ if (!string.IsNullOrEmpty(certFileEnv))
+ {
+ builder.WithEnvironment(certFileEnv, certFileDest);
+ }
+ if (!string.IsNullOrEmpty(certKeyFileEnv))
+ {
+ builder.WithEnvironment(certKeyFileEnv, certKeyFileDest);
+ }
+ }
+ else
+ {
+ // For non-container resources, set the file paths directly
+ if (!string.IsNullOrEmpty(certFileEnv))
+ {
+ builder.WithEnvironment(certFileEnv, certExportPath);
+ }
+
+ if (!string.IsNullOrEmpty(certKeyFileEnv))
+ {
+ builder.WithEnvironment(certKeyFileEnv, certKeyExportPath);
+ }
+ }
+
+ return builder;
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests.csproj
new file mode 100644
index 000000000..001ef9dfa
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs
new file mode 100644
index 000000000..ee24c94ca
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs
@@ -0,0 +1,650 @@
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests;
+
+public class ResourceCreationTests
+{
+ [Fact]
+ public void CanCreateTheCollectorResource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddOpenTelemetryCollector("collector")
+ .WithConfig("./config.yaml")
+ .WithAppForwarding();
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(collectorResource);
+
+ Assert.Equal("collector", collectorResource.Name);
+ }
+
+ [Fact]
+ public async Task CanCreateTheCollectorResourceWithCustomConfig()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddOpenTelemetryCollector("collector")
+ .WithConfig("./config.yaml")
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+ var configMount = collectorResource.Annotations.OfType().SingleOrDefault();
+ Assert.NotNull(configMount);
+ Assert.EndsWith("config.yaml", configMount.Source);
+ Assert.Equal("/config/config.yaml", configMount.Target);
+
+ var args = collectorResource.Annotations.OfType().SingleOrDefault();
+ Assert.NotNull(args);
+ CommandLineArgsCallbackContext context = new([]);
+ var argValues = args.Callback(context);
+ await argValues;
+
+ Assert.Contains("--config=/config/config.yaml", context.Args);
+ }
+
+ [Fact]
+ public async Task CanCreateTheCollectorResourceWithMultipleConfigs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddOpenTelemetryCollector("collector")
+ .WithConfig("./config.yaml")
+ .WithConfig("./config2.yaml")
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var configMounts = collectorResource.Annotations.OfType().ToList();
+ Assert.Equal(2, configMounts.Count);
+ Assert.Collection(configMounts,
+ m =>
+ {
+ Assert.EndsWith("config.yaml", m.Source);
+ Assert.Equal("/config/config.yaml", m.Target);
+ },
+ m =>
+ {
+ Assert.EndsWith("config2.yaml", m.Source);
+ Assert.Equal("/config/config2.yaml", m.Target);
+ });
+
+ var args = collectorResource.Annotations.OfType();
+ Assert.NotNull(args);
+ CommandLineArgsCallbackContext context = new([]);
+ foreach (var arg in args)
+ {
+ var argValues = arg.Callback(context);
+ await argValues;
+ }
+
+ Assert.Contains("--config=/config/config.yaml", context.Args);
+ Assert.Contains("--config=/config/config2.yaml", context.Args);
+ }
+
+ [Fact]
+ public void CanDisableGrpcEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddOpenTelemetryCollector("collector", settings => settings.EnableGrpcEndpoint = false)
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ Assert.DoesNotContain(endpoints, e => e.Name == "grpc");
+ Assert.Contains(endpoints, e => e.Name == "http");
+ }
+
+ [Fact]
+ public void CanDisableHttpEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddOpenTelemetryCollector("collector", settings => settings.EnableHttpEndpoint = false)
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ Assert.Contains(endpoints, e => e.Name == "grpc");
+ Assert.DoesNotContain(endpoints, e => e.Name == "http");
+ }
+
+ [Fact]
+ public void CanDisableBothEndpoints()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.EnableHttpEndpoint = false;
+ settings.EnableGrpcEndpoint = false;
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ Assert.Empty(collectorResource.Annotations.OfType());
+ }
+
+ [Fact]
+ public void ContainerHasAspireEnvironmentVariables()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var envs = collectorResource.Annotations.OfType().ToList();
+ Assert.NotEmpty(envs);
+
+ var context = new EnvironmentCallbackContext(new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)));
+ foreach (var env in envs)
+ {
+ env.Callback(context);
+ }
+
+ Assert.Contains("ASPIRE_ENDPOINT", context.EnvironmentVariables.Keys);
+ Assert.Contains("ASPIRE_API_KEY", context.EnvironmentVariables.Keys);
+ Assert.Equal("http://host.docker.internal:18889", context.EnvironmentVariables["ASPIRE_ENDPOINT"]);
+ Assert.NotNull(context.EnvironmentVariables["ASPIRE_API_KEY"]);
+ }
+
+ [Fact]
+ public void CanForceNonSSLForTheCollector()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings => settings.ForceNonSecureReceiver = true)
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ var grpcEndpoint = endpoints.Single(e => e.Name == "grpc");
+ var httpEndpoint = endpoints.Single(e => e.Name == "http");
+ Assert.Equal("http", grpcEndpoint.UriScheme);
+ Assert.Equal("http", httpEndpoint.UriScheme);
+ }
+
+ [Fact]
+ public void CollectorUsesCustomImageAndTag()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.CollectorTag = "mytag";
+ settings.Registry = "myregistry.io";
+ settings.Image = "myorg/mycollector";
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ Assert.True(collectorResource.TryGetLastAnnotation(out ContainerImageAnnotation? imageAnnotations));
+ Assert.NotNull(imageAnnotations);
+ Assert.Equal("mytag", imageAnnotations.Tag);
+ Assert.Equal("myregistry.io/myorg/mycollector", imageAnnotations.Image);
+ // Registry is likely set to null/empty when the full path is provided as image
+ Assert.Null(imageAnnotations.Registry);
+ }
+
+ [Fact]
+ public void CollectorEndpointsUseHttpsWhenDashboardIsHttps()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ var grpcEndpoint = endpoints.Single(e => e.Name == "grpc");
+ var httpEndpoint = endpoints.Single(e => e.Name == "http");
+ Assert.Equal("https", grpcEndpoint.UriScheme);
+ Assert.Equal("https", httpEndpoint.UriScheme);
+ }
+
+ [Fact]
+ public void CanConfigureOnlyGrpcEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.EnableGrpcEndpoint = true;
+ settings.EnableHttpEndpoint = false;
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ Assert.Single(endpoints);
+ var grpcEndpoint = endpoints.Single();
+ Assert.Equal("grpc", grpcEndpoint.Name);
+ Assert.Equal(4317, grpcEndpoint.TargetPort);
+ Assert.Equal("http", grpcEndpoint.UriScheme);
+ }
+
+ [Fact]
+ public void CanConfigureOnlyHttpEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.EnableGrpcEndpoint = false;
+ settings.EnableHttpEndpoint = true;
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ Assert.Single(endpoints);
+ var httpEndpoint = endpoints.Single();
+ Assert.Equal("http", httpEndpoint.Name);
+ Assert.Equal(4318, httpEndpoint.TargetPort);
+ Assert.Equal("http", httpEndpoint.UriScheme);
+ }
+
+ [Fact]
+ public void ForceNonSecureReceiverOverridesHttpsEndpoints()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = true;
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ var endpoints = collectorResource.Annotations.OfType().ToList();
+ var grpcEndpoint = endpoints.Single(e => e.Name == "grpc");
+ var httpEndpoint = endpoints.Single(e => e.Name == "http");
+
+ // Even though dashboard is HTTPS, ForceNonSecureReceiver should make endpoints HTTP
+ Assert.Equal("http", grpcEndpoint.UriScheme);
+ Assert.Equal("http", httpEndpoint.UriScheme);
+ }
+
+ [Fact]
+ public void DevCertificateLogicIsNotTriggeredInNonDevelopmentEnvironment()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // In non-development environment (default test environment), dev cert args should not be added
+ var args = collectorResource.Annotations.OfType().ToList();
+ var context = new CommandLineArgsCallbackContext([]);
+ foreach (var arg in args)
+ {
+ arg.Callback(context);
+ }
+
+ // Should not contain TLS certificate configuration args since we're not in Development environment with RunMode
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::http::tls::cert_file"));
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::grpc::tls::cert_file"));
+ }
+
+ [Fact]
+ public void DevCertificateLogicIsNotTriggeredWhenForceNonSecureReceiverEnabled()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = true; // Force HTTP even with HTTPS dashboard
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Check that no certificate-related arguments were added
+ var args = collectorResource.Annotations.OfType().ToList();
+ var context = new CommandLineArgsCallbackContext([]);
+ foreach (var arg in args)
+ {
+ arg.Callback(context);
+ }
+
+ // Should not contain TLS certificate configuration args
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::http::tls::cert_file"));
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::grpc::tls::cert_file"));
+ }
+
+ [Fact]
+ public void RunWithHttpsDevCertificateAddsExecutableResourceInRunMode()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ // Should have created a dev-cert-export executable resource
+ var devCertExportResource = appModel.Resources.OfType()
+ .SingleOrDefault(r => r.Name == "dev-cert-export");
+ Assert.NotNull(devCertExportResource);
+
+ // Verify it's configured to run dotnet dev-certs
+ var args = devCertExportResource.Annotations.OfType().ToList();
+ Assert.NotEmpty(args);
+
+ var context = new CommandLineArgsCallbackContext([]);
+ foreach (var arg in args)
+ {
+ arg.Callback(context);
+ }
+
+ Assert.Contains("dev-certs", context.Args.Cast());
+ Assert.Contains("https", context.Args.Cast());
+ Assert.Contains("--export-path", context.Args.Cast());
+ Assert.Contains("--format", context.Args.Cast());
+ Assert.Contains("Pem", context.Args.Cast());
+ Assert.Contains("--no-password", context.Args.Cast());
+ }
+
+ [Fact]
+ public void RunWithHttpsDevCertificateAddsContainerFilesAndWaitAnnotation()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Should have a WaitAnnotation for the dev-cert-export resource
+ var waitAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertWaitAnnotation = waitAnnotations.FirstOrDefault(w => w.Resource.Name == "dev-cert-export");
+ Assert.NotNull(devCertWaitAnnotation);
+ Assert.Equal(WaitType.WaitForCompletion, devCertWaitAnnotation.WaitType);
+
+ // Should have a ContainerFilesAnnotation for the dev certificates
+ var containerFilesAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertFilesAnnotation = containerFilesAnnotations.FirstOrDefault(cf => cf.DestinationPath == "/dev-certs");
+ Assert.NotNull(devCertFilesAnnotation);
+ }
+
+ [Fact]
+ public void RunWithHttpsDevCertificateNotTriggeredInNonRunMode()
+ {
+ // Use regular builder (not TestDistributedApplicationBuilder.Create) which defaults to non-Run mode
+ var builder = DistributedApplication.CreateBuilder();
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ // Should NOT have created a dev-cert-export executable resource
+ var devCertExportResource = appModel.Resources.OfType()
+ .SingleOrDefault(r => r.Name == "dev-cert-export");
+ Assert.Null(devCertExportResource);
+
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Should NOT have container files annotation for dev certs
+ var containerFilesAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertFilesAnnotation = containerFilesAnnotations.FirstOrDefault(cf => cf.DestinationPath == "/dev-certs");
+ Assert.Null(devCertFilesAnnotation);
+ }
+
+ [Fact]
+ public void RunWithHttpsDevCertificateNotTriggeredWhenForceNonSecureEnabled()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = true; // Force non-secure
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ // Should NOT have created a dev-cert-export executable resource
+ var devCertExportResource = appModel.Resources.OfType()
+ .SingleOrDefault(r => r.Name == "dev-cert-export");
+ Assert.Null(devCertExportResource);
+
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Should NOT have container files annotation for dev certs
+ var containerFilesAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertFilesAnnotation = containerFilesAnnotations.FirstOrDefault(cf => cf.DestinationPath == "/dev-certs");
+ Assert.Null(devCertFilesAnnotation);
+ }
+
+ [Fact]
+ public void DevCertificateResourcesAddedWhenHttpsEnabledInDevelopment()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ // Should have created a dev-cert-export executable resource
+ var devCertExportResource = appModel.Resources.OfType()
+ .SingleOrDefault(r => r.Name == "dev-cert-export");
+ Assert.NotNull(devCertExportResource);
+ Assert.Equal("dotnet", devCertExportResource.Command);
+
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Should have container files annotation for dev certs
+ var containerFilesAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertFilesAnnotation = containerFilesAnnotations.FirstOrDefault(cf => cf.DestinationPath == "/dev-certs");
+ Assert.NotNull(devCertFilesAnnotation);
+
+ // Should have wait annotation for the dev-cert-export resource
+ var waitAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertWaitAnnotation = waitAnnotations.FirstOrDefault(wa => wa.Resource == devCertExportResource);
+ Assert.NotNull(devCertWaitAnnotation);
+ }
+
+ [Fact]
+ public void DevCertificateContainerFilesOnlyAddedForEnabledEndpointsInRunMode()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.EnableGrpcEndpoint = true;
+ settings.EnableHttpEndpoint = false; // Only enable gRPC
+ settings.ForceNonSecureReceiver = false;
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var collectorResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(collectorResource);
+
+ // Should have container files annotation for dev certs
+ var containerFilesAnnotations = collectorResource.Annotations.OfType().ToList();
+ var devCertFilesAnnotation = containerFilesAnnotations.FirstOrDefault(cf => cf.DestinationPath == "/dev-certs");
+ Assert.NotNull(devCertFilesAnnotation);
+
+ // Verify the TLS arguments are only added for enabled endpoints
+ var args = collectorResource.Annotations.OfType().ToList();
+ var context = new CommandLineArgsCallbackContext([]);
+ foreach (var arg in args)
+ {
+ arg.Callback(context);
+ }
+
+ // Should only contain gRPC TLS args, not HTTP TLS args
+ Assert.Contains(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::grpc::tls::cert_file"));
+ Assert.Contains(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::grpc::tls::key_file"));
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::http::tls::cert_file"));
+ Assert.DoesNotContain(context.Args.Cast(), a => a.Contains("receivers::otlp::protocols::http::tls::key_file"));
+ }
+
+ [Fact]
+ public void DevCertificateExecutableResourceHasCorrectConfiguration()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
+ builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:18889";
+
+ builder.AddOpenTelemetryCollector("collector", settings =>
+ {
+ settings.ForceNonSecureReceiver = false; // Allow HTTPS
+ })
+ .WithAppForwarding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ // Verify the dev-cert-export executable has correct configuration
+ var devCertExportResource = appModel.Resources.OfType()
+ .SingleOrDefault(r => r.Name == "dev-cert-export");
+ Assert.NotNull(devCertExportResource);
+ Assert.Equal("dotnet", devCertExportResource.Command);
+
+ // Check the environment variable for consistent language
+ var envAnnotations = devCertExportResource.Annotations.OfType().ToList();
+ Assert.NotEmpty(envAnnotations);
+
+ var envContext = new EnvironmentCallbackContext(new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)));
+ foreach (var env in envAnnotations)
+ {
+ env.Callback(envContext);
+ }
+
+ Assert.Contains("DOTNET_CLI_UI_LANGUAGE", envContext.EnvironmentVariables.Keys);
+ Assert.Equal("en", envContext.EnvironmentVariables["DOTNET_CLI_UI_LANGUAGE"]);
+
+ // Check the arguments for certificate export
+ var argsAnnotations = devCertExportResource.Annotations.OfType().ToList();
+ Assert.NotEmpty(argsAnnotations);
+
+ var argsContext = new CommandLineArgsCallbackContext([]);
+ foreach (var arg in argsAnnotations)
+ {
+ arg.Callback(argsContext);
+ }
+
+ Assert.Contains("dev-certs", argsContext.Args);
+ Assert.Contains("https", argsContext.Args);
+ Assert.Contains("--export-path", argsContext.Args);
+ Assert.Contains("--format", argsContext.Args);
+ Assert.Contains("Pem", argsContext.Args);
+ Assert.Contains("--no-password", argsContext.Args);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/RoutingExtensionTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/RoutingExtensionTests.cs
new file mode 100644
index 000000000..7b5e98316
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/RoutingExtensionTests.cs
@@ -0,0 +1,156 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests;
+
+public class RoutingExtensionTests
+{
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingAddsWaitAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"))
+ .WithOpenTelemetryCollectorRouting(collector);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(resource);
+
+ // Verify WaitAnnotation is added
+ var waitAnnotation = resource.Annotations.OfType().SingleOrDefault();
+ Assert.NotNull(waitAnnotation);
+ Assert.Same(collector.Resource, waitAnnotation.Resource);
+ Assert.Equal(WaitType.WaitUntilHealthy, waitAnnotation.WaitType);
+ }
+
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingAddsEnvironmentCallback()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"))
+ .WithOpenTelemetryCollectorRouting(collector);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(resource);
+
+ // Verify EnvironmentCallbackAnnotation is added by the routing extension
+ var envAnnotations = resource.Annotations.OfType().ToList();
+ Assert.NotEmpty(envAnnotations);
+
+ // There should be exactly one environment callback from the routing extension
+ Assert.Single(envAnnotations);
+ }
+
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingReadsOtlpProtocolFromEnvironment()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"))
+ .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "http")
+ .WithOpenTelemetryCollectorRouting(collector);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(resource);
+
+ // Verify we have environment callbacks (one from WithEnvironment, one from routing)
+ var envAnnotations = resource.Annotations.OfType().ToList();
+ Assert.Equal(2, envAnnotations.Count);
+
+ // The routing extension should add an environment callback that reads OTEL_EXPORTER_OTLP_PROTOCOL
+ // We can't easily test the actual callback execution without endpoint allocation,
+ // but we can verify the callback exists
+ Assert.NotEmpty(envAnnotations);
+ }
+
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingDefaultsToGrpcProtocol()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"))
+ .WithOpenTelemetryCollectorRouting(collector);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(resource);
+
+ // Verify the routing callback exists - it will default to 'grpc' if no protocol is set
+ var envAnnotations = resource.Annotations.OfType().ToList();
+ Assert.Single(envAnnotations);
+ }
+
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingAddsCorrectAnnotationsToResource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"))
+ .WithOpenTelemetryCollectorRouting(collector);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.NotNull(resource);
+
+ // Verify both required annotations are present
+ var waitAnnotation = resource.Annotations.OfType().SingleOrDefault();
+ var envAnnotation = resource.Annotations.OfType().SingleOrDefault();
+
+ Assert.NotNull(waitAnnotation);
+ Assert.NotNull(envAnnotation);
+
+ // Verify the wait annotation points to the collector resource
+ Assert.Same(collector.Resource, waitAnnotation.Resource);
+ Assert.Equal(WaitType.WaitUntilHealthy, waitAnnotation.WaitType);
+ }
+
+ [Fact]
+ public void WithOpenTelemetryCollectorRoutingReturnsOriginalBuilder()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var collector = builder.AddOpenTelemetryCollector("collector")
+ .WithAppForwarding();
+
+ var testResource = builder.AddResource(new TestResource("test-resource"));
+ var result = testResource.WithOpenTelemetryCollectorRouting(collector);
+
+ // Should return the same builder instance for fluent chaining
+ Assert.Same(testResource, result);
+ }
+}
+
+// Test resource that implements IResourceWithEnvironment for testing
+public class TestResource(string name) : Resource(name), IResourceWithEnvironment
+{
+}
\ No newline at end of file