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