From ef60fdf0a466852aa369c4da0021828df6f7941b Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Thu, 7 Aug 2025 21:55:42 +0100 Subject: [PATCH 01/22] Added OpenTelemetry Collector component --- CommunityToolkit.Aspire.slnx | 1 + .../CollectorExtensions.cs | 105 ++++++++++ .../CollectorResource.cs | 23 +++ ...pire.Hosting.OpenTelemetryCollector.csproj | 11 ++ .../EnvironmentVariableHook.cs | 58 ++++++ .../OpenTelemetryCollectorSettings.cs | 36 ++++ src/Shared/DevCertHostingExtensions.cs | 187 ++++++++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs create mode 100644 src/Shared/DevCertHostingExtensions.cs diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a1c768323..653ad5a5b 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -172,6 +172,7 @@ + diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs new file mode 100644 index 000000000..cef64a711 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs @@ -0,0 +1,105 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting; + +/// +/// Extension methods to add the collector resource +/// +public static class CollectorExtensions +{ + 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 CollectorResource(name); + var resourceBuilder = builder.AddResource(resource) + .WithImage(settings.CollectorImage, settings.CollectorVersion) + .WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]); + + if (settings.EnableGrpcEndpoint) + resourceBuilder.WithEndpoint(targetPort: 4317, name: CollectorResource.GRPCEndpointName, scheme: isHttpsEnabled ? "https" : "http"); + if (settings.EnableHttpEndpoint) + resourceBuilder.WithEndpoint(targetPort: 4318, name: CollectorResource.HTTPEndpointName, scheme: isHttpsEnabled ? "https" : "http"); + + + if (!settings.ForceNonSecureReceiver && isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment()) + { + DevCertHostingExtensions.RunWithHttpsDevCertificate(resourceBuilder, "HTTPS_CERT_FILE", "HTTPS_CERT_KEY_FILE", (certFilePath, certKeyPath) => + { + 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.ApplicationBuilder.Services.TryAddLifecycleHook(); + 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}"); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs new file mode 100644 index 000000000..5a6530e28 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs @@ -0,0 +1,23 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// The collector resource +/// +/// Name of the resource +public class CollectorResource(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/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj new file mode 100644 index 000000000..f38677991 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj @@ -0,0 +1,11 @@ + + + + An Aspire component to add an OpenTelemetry Collector into the OTLP pipeline + opentelemetry observability + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs new file mode 100644 index 000000000..38780a842 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs @@ -0,0 +1,58 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Hooks to add the OTLP environment variables to the various containers +/// +/// +public class EnvironmentVariableHook(ILogger logger) : IDistributedApplicationLifecycleHook +{ + /// + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken) + { + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource is null) + { + logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + + 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) + { + logger.LogDebug("Forwarding Telemetry for {name} to the collector", resourceItem.Name); + if (resourceItem is null) continue; + + resourceItem.Annotations.Add(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => + { + var protocol = context.EnvironmentVariables.GetValueOrDefault("OTEL_EXPORTER_OTLP_PROTOCOL", ""); + var endpoint = protocol.ToString() == "http/protobuf" ? httpEndpoint : grpcEndpoint; + + if (endpoint == null) + { + logger.LogWarning("No {protocol} endpoint on the collector for {resourceName} to use", + protocol, resourceItem.Name); + return; + } + + if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) + context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); + context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url); + })); + } + + return Task.CompletedTask; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs new file mode 100644 index 000000000..a1aeeb802 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs @@ -0,0 +1,36 @@ +namespace Aspire.Hosting; + +/// +/// Settings for the OpenTelemetry Collector +/// +public class OpenTelemetryCollectorSettings +{ + /// + /// The version of the collector, defaults to latest + /// + public string CollectorVersion { get; set; } = "latest"; + + /// + /// The image of the collector, defaults to ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib + /// + public string CollectorImage { get; set; } = "ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib"; + + /// + /// 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/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs new file mode 100644 index 000000000..8436b4b86 --- /dev/null +++ b/src/Shared/DevCertHostingExtensions.cs @@ -0,0 +1,187 @@ +namespace Aspire.Hosting; + +public static class DevCertHostingExtensions +{ + /// + /// 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 bind mounted into the container. + ///
+ /// + /// 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, Action? onSuccessfulExport = null) + where TResource : IResourceWithEnvironment + { + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment()) + { + builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => + { + var logger = e.Services.GetRequiredService().GetLogger(builder.Resource); + + // Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via + // the specified environment variables. + var (exported, certPath, certKeyPath) = await TryExportDevCertificateAsync(builder.ApplicationBuilder, logger); + + if (!exported) + { + // The export failed for some reason, don't configure the resource to use the certificate. + return; + } + + var certKeyFileDest = ""; + var certFileDest = ""; + + if (builder.Resource is ContainerResource containerResource) + { + // Bind-mount the certificate files into the container. + const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs"; + + var certFileName = Path.GetFileName(certPath); + var certKeyFileName = Path.GetFileName(certKeyPath); + + var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException(); + + certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}"; + certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}"; + + containerResource.TryGetContainerMounts(out var mounts); + if (mounts is null || !mounts.Any(m => m.Source == bindSource)) + { + builder.ApplicationBuilder.CreateResourceBuilder(containerResource) + .WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: false) + .WithEnvironment(certFileEnv, certFileDest) + .WithEnvironment(certKeyFileEnv, certKeyFileDest); + } + } + else + { + builder + .WithEnvironment(certFileEnv, certPath) + .WithEnvironment(certKeyFileEnv, certKeyPath); + } + + if (onSuccessfulExport is not null) + { + onSuccessfulExport(certFileDest, certKeyFileDest); + } + }); + } + + return builder; + } + + private static async Task<(bool, string CertFilePath, string CertKeyFilPath)> TryExportDevCertificateAsync(IDistributedApplicationBuilder builder, ILogger logger) + { + // Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary + // directory and returns the path. + // TODO: Check if we're running on a platform that already has the cert and key exported to a file (e.g. macOS) and just use those instead. + var appNameHash = builder.Configuration["AppHost:Sha256"]![..10]; + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}"); + var certExportPath = Path.Combine(tempDir, "dev-cert.pem"); + var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key"); + + if (File.Exists(certExportPath) && File.Exists(certKeyExportPath)) + { + // Certificate already exported, return the path. + logger.LogDebug("Using previously exported dev cert files '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath); + return (true, certExportPath, certKeyExportPath); + } + + if (File.Exists(certExportPath)) + { + logger.LogTrace("Deleting previously exported dev cert file '{CertPath}'", certExportPath); + File.Delete(certExportPath); + } + + if (File.Exists(certKeyExportPath)) + { + logger.LogTrace("Deleting previously exported dev cert key file '{CertKeyPath}'", certKeyExportPath); + File.Delete(certKeyExportPath); + } + + if (!Directory.Exists(tempDir)) + { + logger.LogTrace("Creating directory to export dev cert to '{ExportDir}'", tempDir); + Directory.CreateDirectory(tempDir); + } + + string[] args = ["dev-certs", "https", "--export-path", $"\"{certExportPath}\"", "--format", "Pem", "--no-password"]; + var argsString = string.Join(' ', args); + + logger.LogTrace("Running command to export dev cert: {ExportCmd}", $"dotnet {argsString}"); + var exportStartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = argsString, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + + var exportProcess = new Process { StartInfo = exportStartInfo }; + + Task? stdOutTask = null; + Task? stdErrTask = null; + + try + { + try + { + if (exportProcess.Start()) + { + stdOutTask = ConsumeOutput(exportProcess.StandardOutput, msg => logger.LogInformation("> {StandardOutput}", msg)); + stdErrTask = ConsumeOutput(exportProcess.StandardError, msg => logger.LogError("! {ErrorOutput}", msg)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start HTTPS dev certificate export process"); + return default; + } + + var timeout = TimeSpan.FromSeconds(5); + var exited = exportProcess.WaitForExit(timeout); + + if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath)) + { + logger.LogDebug("Dev cert exported to '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath); + return (true, certExportPath, certKeyExportPath); + } + + if (exportProcess.HasExited && exportProcess.ExitCode != 0) + { + logger.LogError("HTTPS dev certificate export failed with exit code {ExitCode}", exportProcess.ExitCode); + } + else if (!exportProcess.HasExited) + { + exportProcess.Kill(true); + logger.LogError("HTTPS dev certificate export timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds); + } + else + { + logger.LogError("HTTPS dev certificate export failed for an unknown reason"); + } + return default; + } + finally + { + await Task.WhenAll(stdOutTask ?? Task.CompletedTask, stdErrTask ?? Task.CompletedTask); + } + + static async Task ConsumeOutput(TextReader reader, Action callback) + { + char[] buffer = new char[256]; + int charsRead; + + while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + callback(new string(buffer, 0, charsRead)); + } + } + } +} \ No newline at end of file From 5401d2d93212b4cbe52cef8850b3dae4e5abda8c Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Thu, 7 Aug 2025 21:20:12 +0000 Subject: [PATCH 02/22] Fixed DevCertHostingExtensions --- ...olkit.Aspire.Hosting.OpenTelemetryCollector.csproj | 5 +++++ src/Shared/DevCertHostingExtensions.cs | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj index f38677991..8ae41a461 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj @@ -8,4 +8,9 @@ + + + + +
diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index 8436b4b86..18e40db32 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -1,6 +1,13 @@ -namespace Aspire.Hosting; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Diagnostics; -public static class DevCertHostingExtensions +namespace Aspire; + +internal static class DevCertHostingExtensions { /// /// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when From 68d4102e9aaf6c4cf36c1a91a1f9026d0d127417 Mon Sep 17 00:00:00 2001 From: MartinDotNet Date: Thu, 7 Aug 2025 22:41:45 +0100 Subject: [PATCH 03/22] Update src/Shared/DevCertHostingExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Shared/DevCertHostingExtensions.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index 18e40db32..7991b03cf 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -85,7 +85,12 @@ public static IResourceBuilder RunWithHttpsDevCertificate( // Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary // directory and returns the path. // TODO: Check if we're running on a platform that already has the cert and key exported to a file (e.g. macOS) and just use those instead. - var appNameHash = builder.Configuration["AppHost:Sha256"]![..10]; + var appHostSha256 = builder.Configuration["AppHost:Sha256"]; + if (appHostSha256 is null) + { + throw new InvalidOperationException("Configuration value 'AppHost:Sha256' is missing. Cannot export development certificate."); + } + var appNameHash = appHostSha256[..10]; var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}"); var certExportPath = Path.Combine(tempDir, "dev-cert.pem"); var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key"); From 81ffb8d4fe3b2d79608bf59d540607a0cc629f6f Mon Sep 17 00:00:00 2001 From: MartinDotNet Date: Thu, 7 Aug 2025 22:43:12 +0100 Subject: [PATCH 04/22] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../CollectorExtensions.cs | 2 +- .../EnvironmentVariableHook.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs index cef64a711..911879fec 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs @@ -24,7 +24,7 @@ public static class CollectorExtensions /// public static IResourceBuilder AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder, string name, - Action configureSettings = null!) + Action? configureSettings = null) { var url = builder.Configuration[DashboardOtlpUrlVariableName] ?? builder.Configuration[DashboardOtlpUrlVariableNameLegacy] ?? diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs index 38780a842..11fd498a3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs @@ -23,7 +23,8 @@ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, C } var grpcEndpoint = collectorResource!.GetEndpoint(collectorResource!.GRPCEndpoint.EndpointName); - var httpEndpoint = collectorResource!.GetEndpoint(collectorResource!.HTTPEndpoint.EndpointName); + var grpcEndpoint = collectorResource.GetEndpoint(collectorResource.GRPCEndpoint.EndpointName); + var httpEndpoint = collectorResource.GetEndpoint(collectorResource.HTTPEndpoint.EndpointName); if (!resources.Any()) { From 790c37a8561e6ff36cc721a4cfa8663eacae0041 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 11 Aug 2025 21:09:19 +0000 Subject: [PATCH 05/22] Renames --- .../EnvironmentVariableHook.cs | 11 +++++------ ...ions.cs => OpenTelemetryCollectorExtensions.cs} | 14 +++++++------- ...source.cs => OpenTelemetryCollectorResource.cs} | 10 +++++----- 3 files changed, 17 insertions(+), 18 deletions(-) rename src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/{CollectorExtensions.cs => OpenTelemetryCollectorExtensions.cs} (84%) rename src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/{CollectorResource.cs => OpenTelemetryCollectorResource.cs} (50%) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs index 11fd498a3..602421eca 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs @@ -14,7 +14,7 @@ public class EnvironmentVariableHook(ILogger logger) : public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken) { var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); if (collectorResource is null) { @@ -22,16 +22,15 @@ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, C return Task.CompletedTask; } - var grpcEndpoint = collectorResource!.GetEndpoint(collectorResource!.GRPCEndpoint.EndpointName); - var grpcEndpoint = collectorResource.GetEndpoint(collectorResource.GRPCEndpoint.EndpointName); - var httpEndpoint = collectorResource.GetEndpoint(collectorResource.HTTPEndpoint.EndpointName); + 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) + 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; @@ -41,7 +40,7 @@ public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, C var protocol = context.EnvironmentVariables.GetValueOrDefault("OTEL_EXPORTER_OTLP_PROTOCOL", ""); var endpoint = protocol.ToString() == "http/protobuf" ? httpEndpoint : grpcEndpoint; - if (endpoint == null) + if (endpoint is null) { logger.LogWarning("No {protocol} endpoint on the collector for {resourceName} to use", protocol, resourceItem.Name); diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs similarity index 84% rename from src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs rename to src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index 911879fec..df7812127 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting; /// /// Extension methods to add the collector resource /// -public static class CollectorExtensions +public static class OpenTelemetryCollectorExtensions { private const string DashboardOtlpUrlVariableNameLegacy = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"; private const string DashboardOtlpUrlVariableName = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; @@ -22,7 +22,7 @@ public static class CollectorExtensions /// /// /// - public static IResourceBuilder AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder, + public static IResourceBuilder AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder, string name, Action? configureSettings = null) { @@ -37,16 +37,16 @@ public static IResourceBuilder AddOpenTelemetryCollector(this var dashboardOtlpEndpoint = ReplaceLocalhostWithContainerHost(url, builder.Configuration); - var resource = new CollectorResource(name); + var resource = new OpenTelemetryCollectorResource(name); var resourceBuilder = builder.AddResource(resource) .WithImage(settings.CollectorImage, settings.CollectorVersion) .WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint) .WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]); if (settings.EnableGrpcEndpoint) - resourceBuilder.WithEndpoint(targetPort: 4317, name: CollectorResource.GRPCEndpointName, scheme: isHttpsEnabled ? "https" : "http"); + resourceBuilder.WithEndpoint(targetPort: 4317, name: OpenTelemetryCollectorResource.GrpcEndpointName, scheme: isHttpsEnabled ? "https" : "http"); if (settings.EnableHttpEndpoint) - resourceBuilder.WithEndpoint(targetPort: 4318, name: CollectorResource.HTTPEndpointName, scheme: isHttpsEnabled ? "https" : "http"); + resourceBuilder.WithEndpoint(targetPort: 4318, name: OpenTelemetryCollectorResource.HttpEndpointName, scheme: isHttpsEnabled ? "https" : "http"); if (!settings.ForceNonSecureReceiver && isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment()) @@ -75,7 +75,7 @@ public static IResourceBuilder AddOpenTelemetryCollector(this /// /// /// - public static IResourceBuilder WithAppForwarding(this IResourceBuilder builder) + public static IResourceBuilder WithAppForwarding(this IResourceBuilder builder) { builder.ApplicationBuilder.Services.TryAddLifecycleHook(); return builder; @@ -96,7 +96,7 @@ private static string ReplaceLocalhostWithContainerHost(string value, IConfigura /// /// /// - public static IResourceBuilder WithConfig(this IResourceBuilder builder, string configPath) + public static IResourceBuilder WithConfig(this IResourceBuilder builder, string configPath) { var configFileInfo = new FileInfo(configPath); return builder.WithBindMount(configPath, $"/config/{configFileInfo.Name}") diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs similarity index 50% rename from src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs rename to src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs index 5a6530e28..c6aa90c23 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorResource.cs @@ -6,18 +6,18 @@ namespace Aspire.Hosting; /// The collector resource /// /// Name of the resource -public class CollectorResource(string name) : ContainerResource(name) +public class OpenTelemetryCollectorResource(string name) : ContainerResource(name) { - internal static string GRPCEndpointName = "grpc"; - internal static string HTTPEndpointName = "http"; + internal static string GrpcEndpointName = "grpc"; + internal static string HttpEndpointName = "http"; /// /// gRPC Endpoint /// - public EndpointReference GRPCEndpoint => new(this, GRPCEndpointName); + public EndpointReference GrpcEndpoint => new(this, GrpcEndpointName); /// /// HTTP Endpoint /// - public EndpointReference HTTPEndpoint => new(this, HTTPEndpointName); + public EndpointReference HttpEndpoint => new(this, HttpEndpointName); } From 84a0ecf0d9bf910f14ad11ee21e7647f77088213 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 11 Aug 2025 21:26:44 +0000 Subject: [PATCH 06/22] Switch the image syntax --- .../OpenTelemetryCollectorExtensions.cs | 2 +- .../OpenTelemetryCollectorSettings.cs | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index df7812127..655b22f71 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -39,7 +39,7 @@ public static IResourceBuilder AddOpenTelemetryC var resource = new OpenTelemetryCollectorResource(name); var resourceBuilder = builder.AddResource(resource) - .WithImage(settings.CollectorImage, settings.CollectorVersion) + .WithImage(settings.CollectorImage, settings.CollectorTag) .WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint) .WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]); diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs index a1aeeb802..ddf8c79dc 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs @@ -6,14 +6,24 @@ namespace Aspire.Hosting; public class OpenTelemetryCollectorSettings { /// - /// The version of the collector, defaults to latest + /// The Tag to use for the collector /// - public string CollectorVersion { get; set; } = "latest"; + 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; set; } = "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 From bd8de8aa842ed4e8914cd69f97498d0f5ffe8ed3 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 11 Aug 2025 22:01:03 +0000 Subject: [PATCH 07/22] Move to OnBeforeStart for DevCerts --- src/Shared/DevCertHostingExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index 7991b03cf..aec4396df 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -24,9 +24,9 @@ public static IResourceBuilder RunWithHttpsDevCertificate( { if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment()) { - builder.ApplicationBuilder.Eventing.Subscribe(async (e, ct) => + builder.OnBeforeResourceStarted(async (resource, readyEvent, cancellationToken) => { - var logger = e.Services.GetRequiredService().GetLogger(builder.Resource); + var logger = readyEvent.Services.GetRequiredService().GetLogger(builder.Resource); // Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via // the specified environment variables. From 0240f5c90d673cbf0d03247905dad671a062ef5b Mon Sep 17 00:00:00 2001 From: MartinDotNet Date: Fri, 22 Aug 2025 06:29:32 +0100 Subject: [PATCH 08/22] Updated Devcerts code --- .../OpenTelemetryCollectorExtensions.cs | 28 +- src/Shared/DevCertHostingExtensions.cs | 250 +++++++----------- 2 files changed, 110 insertions(+), 168 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index 655b22f71..d0ce94da3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -51,21 +51,21 @@ public static IResourceBuilder AddOpenTelemetryC if (!settings.ForceNonSecureReceiver && isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment()) { - DevCertHostingExtensions.RunWithHttpsDevCertificate(resourceBuilder, "HTTPS_CERT_FILE", "HTTPS_CERT_KEY_FILE", (certFilePath, certKeyPath) => + 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) { - 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}"""); - } - }); + 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; } diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index aec4396df..fed2f4f39 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -1,199 +1,141 @@ -using Aspire.Hosting; -using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; -namespace Aspire; +namespace Aspire.Hosting; -internal static class DevCertHostingExtensions +/// +/// 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 bind mounted into the container. + /// 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, Action? onSuccessfulExport = null) - where TResource : IResourceWithEnvironment + this IResourceBuilder builder, string certFileEnv = "", string certKeyFileEnv = "") + where TResource : IResourceWithEnvironment, IResourceWithWaitSupport { - if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment()) + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - builder.OnBeforeResourceStarted(async (resource, readyEvent, cancellationToken) => - { - var logger = readyEvent.Services.GetRequiredService().GetLogger(builder.Resource); - - // Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via - // the specified environment variables. - var (exported, certPath, certKeyPath) = await TryExportDevCertificateAsync(builder.ApplicationBuilder, logger); - - if (!exported) - { - // The export failed for some reason, don't configure the resource to use the certificate. - return; - } - - var certKeyFileDest = ""; - var certFileDest = ""; - - if (builder.Resource is ContainerResource containerResource) - { - // Bind-mount the certificate files into the container. - const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs"; - - var certFileName = Path.GetFileName(certPath); - var certKeyFileName = Path.GetFileName(certKeyPath); - - var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException(); - - certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}"; - certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}"; - - containerResource.TryGetContainerMounts(out var mounts); - if (mounts is null || !mounts.Any(m => m.Source == bindSource)) - { - builder.ApplicationBuilder.CreateResourceBuilder(containerResource) - .WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: false) - .WithEnvironment(certFileEnv, certFileDest) - .WithEnvironment(certKeyFileEnv, certKeyFileDest); - } - } - else - { - builder - .WithEnvironment(certFileEnv, certPath) - .WithEnvironment(certKeyFileEnv, certKeyPath); - } - - if (onSuccessfulExport is not null) - { - onSuccessfulExport(certFileDest, certKeyFileDest); - } - }); + return builder; } - return builder; - } - - private static async Task<(bool, string CertFilePath, string CertKeyFilPath)> TryExportDevCertificateAsync(IDistributedApplicationBuilder builder, ILogger logger) - { - // Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary - // directory and returns the path. - // TODO: Check if we're running on a platform that already has the cert and key exported to a file (e.g. macOS) and just use those instead. - var appHostSha256 = builder.Configuration["AppHost:Sha256"]; - if (appHostSha256 is null) + if (builder.Resource is not ContainerResource && + (!string.IsNullOrEmpty(certFileEnv) || !string.IsNullOrEmpty(certKeyFileEnv))) { - throw new InvalidOperationException("Configuration value 'AppHost:Sha256' is missing. Cannot export development certificate."); + throw new InvalidOperationException("RunWithHttpsDevCertificate needs environment variables only for Resources that aren't Containers."); } - var appNameHash = appHostSha256[..10]; - var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}"); - var certExportPath = Path.Combine(tempDir, "dev-cert.pem"); - var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key"); + + // 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"); - if (File.Exists(certExportPath) && File.Exists(certKeyExportPath)) - { - // Certificate already exported, return the path. - logger.LogDebug("Using previously exported dev cert files '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath); - return (true, certExportPath, certKeyExportPath); - } + // Create a unique resource name for the certificate export + var exportResourceName = $"dev-cert-export"; - if (File.Exists(certExportPath)) - { - logger.LogTrace("Deleting previously exported dev cert file '{CertPath}'", certExportPath); - File.Delete(certExportPath); - } + // Check if we already have a certificate export resource + var existingResource = builder.ApplicationBuilder.Resources.FirstOrDefault(r => r.Name == exportResourceName); + IResourceBuilder exportExecutable; - if (File.Exists(certKeyExportPath)) + if (existingResource == null) { - logger.LogTrace("Deleting previously exported dev cert key file '{CertKeyPath}'", certKeyExportPath); - File.Delete(certKeyExportPath); + // 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"); + }); } - - if (!Directory.Exists(tempDir)) + else { - logger.LogTrace("Creating directory to export dev cert to '{ExportDir}'", tempDir); - Directory.CreateDirectory(tempDir); + exportExecutable = builder.ApplicationBuilder.CreateResourceBuilder((ExecutableResource)existingResource); } - string[] args = ["dev-certs", "https", "--export-path", $"\"{certExportPath}\"", "--format", "Pem", "--no-password"]; - var argsString = string.Join(' ', args); + builder.WaitForCompletion(exportExecutable); - logger.LogTrace("Running command to export dev cert: {ExportCmd}", $"dotnet {argsString}"); - var exportStartInfo = new ProcessStartInfo + // Configure the current resource with the certificate paths + if (builder.Resource is ContainerResource containerResource) { - FileName = "dotnet", - Arguments = argsString, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden, - }; + // Use WithContainerFiles to provide the certificate files to the container - var exportProcess = new Process { StartInfo = exportStartInfo }; - Task? stdOutTask = null; - Task? stdErrTask = null; + var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{CERT_FILE_NAME}"; + var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{CERT_KEY_FILE_NAME}"; - try - { - try - { - if (exportProcess.Start()) + builder.ApplicationBuilder.CreateResourceBuilder(containerResource) + .WithContainerFiles(DEV_CERT_BIND_MOUNT_DEST_DIR, (context, cancellationToken) => { - stdOutTask = ConsumeOutput(exportProcess.StandardOutput, msg => logger.LogInformation("> {StandardOutput}", msg)); - stdErrTask = ConsumeOutput(exportProcess.StandardError, msg => logger.LogError("! {ErrorOutput}", msg)); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to start HTTPS dev certificate export process"); - return default; - } + 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 + }); + } - var timeout = TimeSpan.FromSeconds(5); - var exited = exportProcess.WaitForExit(timeout); + if (File.Exists(certKeyExportPath)) + { + files.Add(new ContainerFile + { + Name = CERT_KEY_FILE_NAME, + SourcePath = certKeyExportPath + }); + } - if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath)) - { - logger.LogDebug("Dev cert exported to '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath); - return (true, certExportPath, certKeyExportPath); - } + return Task.FromResult(files.AsEnumerable()); + }); - if (exportProcess.HasExited && exportProcess.ExitCode != 0) - { - logger.LogError("HTTPS dev certificate export failed with exit code {ExitCode}", exportProcess.ExitCode); - } - else if (!exportProcess.HasExited) + if (!string.IsNullOrEmpty(certFileEnv)) { - exportProcess.Kill(true); - logger.LogError("HTTPS dev certificate export timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds); + builder.WithEnvironment(certFileEnv, certFileDest); } - else + if (!string.IsNullOrEmpty(certKeyFileEnv)) { - logger.LogError("HTTPS dev certificate export failed for an unknown reason"); + builder.WithEnvironment(certKeyFileEnv, certKeyFileDest); } - return default; } - finally + else { - await Task.WhenAll(stdOutTask ?? Task.CompletedTask, stdErrTask ?? Task.CompletedTask); - } - - static async Task ConsumeOutput(TextReader reader, Action callback) - { - char[] buffer = new char[256]; - int charsRead; - while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - callback(new string(buffer, 0, charsRead)); - } + // For non-container resources, set the file paths directly + builder + .WithEnvironment(certFileEnv, certExportPath) + .WithEnvironment(certKeyFileEnv, certKeyExportPath); } + + return builder; } } \ No newline at end of file From 8b546689e4bd3307a6ad50d96a2515e4a79ea709 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Fri, 29 Aug 2025 13:06:45 +0000 Subject: [PATCH 09/22] Updates to devcerts to check for duplicate mounts. --- src/Shared/DevCertHostingExtensions.cs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index fed2f4f39..76dabc080 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -85,12 +85,19 @@ public static IResourceBuilder RunWithHttpsDevCertificate( // Configure the current resource with the certificate paths if (builder.Resource is ContainerResource containerResource) { - // Use WithContainerFiles to provide the certificate files to the container 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) => { @@ -129,11 +136,16 @@ public static IResourceBuilder RunWithHttpsDevCertificate( } else { - // For non-container resources, set the file paths directly - builder - .WithEnvironment(certFileEnv, certExportPath) - .WithEnvironment(certKeyFileEnv, certKeyExportPath); + if (!string.IsNullOrEmpty(certFileEnv)) + { + builder.WithEnvironment(certFileEnv, certExportPath); + } + + if (!string.IsNullOrEmpty(certKeyFileEnv)) + { + builder.WithEnvironment(certKeyFileEnv, certKeyExportPath); + } } return builder; From 19be5bad3abd938f26925e284cb4b0c1cd2df7ea Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Fri, 29 Aug 2025 13:07:12 +0000 Subject: [PATCH 10/22] Added an example of the collector usage --- CommunityToolkit.Aspire.slnx | 5 + ....Hosting.OpenTelemetryCollector.Api.csproj | 12 ++ ...re.Hosting.OpenTelemetryCollector.Api.http | 6 + .../Program.cs | 31 +++++ .../Properties/launchSettings.json | 23 ++++ .../appsettings.json | 9 ++ .../AppHost.cs | 9 ++ ...ting.OpenTelemetryCollector.AppHost.csproj | 21 +++ .../Properties/launchSettings.json | 29 ++++ .../appsettings.json | 9 ++ .../config.yaml | 43 ++++++ ...nTelemetryCollector.ServiceDefaults.csproj | 21 +++ .../Extensions.cs | 126 ++++++++++++++++++ 13 files changed, 344 insertions(+) create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.csproj create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api.http create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Program.cs create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/Properties/launchSettings.json create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Api/appsettings.json create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/AppHost.cs create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost.csproj create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/Properties/launchSettings.json create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/appsettings.json create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.AppHost/config.yaml create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults.csproj create mode 100644 examples/opentelemetry-collector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.ServiceDefaults/Extensions.cs diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 653ad5a5b..f9cb2df8e 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -105,6 +105,11 @@ + + + + + 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; + } +} From 5f9f44207e75667617e81c88a1ee3e0699ab18d9 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Fri, 29 Aug 2025 17:25:31 +0000 Subject: [PATCH 11/22] Moved to eventing and made all other resources wait for this collector --- .../EnvironmentVariableEvent.cs | 95 +++++++++++++++++++ .../EnvironmentVariableHook.cs | 58 ----------- .../OpenTelemetryCollectorExtensions.cs | 5 +- 3 files changed, 98 insertions(+), 60 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs delete mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs new file mode 100644 index 000000000..7b32188e2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs @@ -0,0 +1,95 @@ +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Hooks to add the OTLP environment variables to the various resources +/// +public static class EnvironmentVariableEventExtention +{ + /// + /// + /// + /// + /// + public static IResourceBuilder WithFirstStartup(this IResourceBuilder builder) + { + builder.OnBeforeResourceStarted((resource, beforeStartedEvent, cancellationToken) => + { + var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(resource); + var appModel = beforeStartedEvent.Services.GetRequiredService(); + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource is null) + { + logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + 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 + /// + /// + public static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder) + { + builder.OnResourceEndpointsAllocated((resource, allocatedEvent, cancellationToken) => + { + var logger = allocatedEvent.Services.GetRequiredService().GetLogger(resource); + var appModel = allocatedEvent.Services.GetRequiredService(); + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource is null) + { + logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + + 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; + } + + if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) + context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); + context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url); + })); + } + + return Task.CompletedTask; + }); + + return builder; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs deleted file mode 100644 index 602421eca..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting; - -/// -/// Hooks to add the OTLP environment variables to the various containers -/// -/// -public class EnvironmentVariableHook(ILogger logger) : IDistributedApplicationLifecycleHook -{ - /// - public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken) - { - var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); - - if (collectorResource is null) - { - logger.LogWarning("No collector resource found"); - return Task.CompletedTask; - } - - 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((EnvironmentCallbackContext 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; - } - - if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) - context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); - context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url); - })); - } - - return Task.CompletedTask; - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index d0ce94da3..9a5b7443c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -1,5 +1,4 @@ using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; @@ -77,7 +76,9 @@ public static IResourceBuilder AddOpenTelemetryC /// public static IResourceBuilder WithAppForwarding(this IResourceBuilder builder) { - builder.ApplicationBuilder.Services.TryAddLifecycleHook(); + builder.AddEnvironmentVariablesEventHook() + .WithFirstStartup(); + return builder; } From f908dda74ee1e2d2948ba70a48ae70e4b7dba3b1 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Sun, 31 Aug 2025 20:56:32 +0000 Subject: [PATCH 12/22] made the extension methods internal --- .../EnvironmentVariableEvent.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs index 7b32188e2..c1f272918 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs @@ -7,14 +7,14 @@ namespace Aspire.Hosting; /// /// Hooks to add the OTLP environment variables to the various resources /// -public static class EnvironmentVariableEventExtention +internal static class EnvironmentVariableEventExtention { /// /// /// /// /// - public static IResourceBuilder WithFirstStartup(this IResourceBuilder builder) + internal static IResourceBuilder WithFirstStartup(this IResourceBuilder builder) { builder.OnBeforeResourceStarted((resource, beforeStartedEvent, cancellationToken) => { @@ -41,7 +41,7 @@ public static IResourceBuilder WithFirstStartup( /// Sets up the OnResourceEndpointsAllocated event to add/update the OTLP environment variables for the collector to the various resources ///
/// - public static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder) + internal static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder) { builder.OnResourceEndpointsAllocated((resource, allocatedEvent, cancellationToken) => { From 26c32d47d5dc2d05e7fffed2d95b38bf581aba37 Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Sun, 31 Aug 2025 21:01:17 +0000 Subject: [PATCH 13/22] Added a basic test --- CommunityToolkit.Aspire.slnx | 1 + ...osting.OpenTelemetryCollector.Tests.csproj | 12 +++++++++ .../ResourceCreationTests.cs | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f9cb2df8e..e262de04e 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -227,6 +227,7 @@ + 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..97b4b69ed --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests.csproj @@ -0,0 +1,12 @@ + + + + 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..0937313f1 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs @@ -0,0 +1,25 @@ +using Aspire.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); + } +} From 11d3af2f9a99726b337b13e14a5c1df38d04982a Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 1 Sep 2025 12:53:03 +0000 Subject: [PATCH 14/22] Reworked Extensions and added README with examples --- .../EnvironmentVariableEvent.cs | 95 ------------------- .../OpenTelemetryCollectorExtensions.cs | 86 +++++++++++++++++ ...OpenTelemetryCollectorRoutingExtensions.cs | 35 +++++++ .../README.md | 41 ++++++++ 4 files changed, 162 insertions(+), 95 deletions(-) delete mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorRoutingExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/README.md diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs deleted file mode 100644 index c1f272918..000000000 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableEvent.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting; - -/// -/// Hooks to add the OTLP environment variables to the various resources -/// -internal static class EnvironmentVariableEventExtention -{ - /// - /// - /// - /// - /// - internal static IResourceBuilder WithFirstStartup(this IResourceBuilder builder) - { - builder.OnBeforeResourceStarted((resource, beforeStartedEvent, cancellationToken) => - { - var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(resource); - var appModel = beforeStartedEvent.Services.GetRequiredService(); - var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); - - if (collectorResource is null) - { - logger.LogWarning("No collector resource found"); - return Task.CompletedTask; - } - 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 - /// - /// - internal static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder) - { - builder.OnResourceEndpointsAllocated((resource, allocatedEvent, cancellationToken) => - { - var logger = allocatedEvent.Services.GetRequiredService().GetLogger(resource); - var appModel = allocatedEvent.Services.GetRequiredService(); - var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); - - if (collectorResource is null) - { - logger.LogWarning("No collector resource found"); - return Task.CompletedTask; - } - - 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; - } - - if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) - context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); - context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url); - })); - } - - return Task.CompletedTask; - }); - - return builder; - } -} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index 9a5b7443c..34bdcec5e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -1,6 +1,8 @@ using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -103,4 +105,88 @@ public static IResourceBuilder WithConfig(this I 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((resource, beforeStartedEvent, cancellationToken) => + { + var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(resource); + var appModel = beforeStartedEvent.Services.GetRequiredService(); + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource is null) + { + logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + 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((resource, allocatedEvent, cancellationToken) => + { + var logger = allocatedEvent.Services.GetRequiredService().GetLogger(resource); + var appModel = allocatedEvent.Services.GetRequiredService(); + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource is null) + { + logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + + 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; + } + + if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) + 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/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/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(); +``` From 267b29157089955222013f7cf725b6b3e3b7883a Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 1 Sep 2025 12:53:15 +0000 Subject: [PATCH 15/22] Added tests to workflow --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) 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, From 2b48b7abb5305f6d5b2613bbfeb0cd520ce6120a Mon Sep 17 00:00:00 2001 From: Martin Thwaites Date: Mon, 1 Sep 2025 14:42:06 +0000 Subject: [PATCH 16/22] Added more tests --- .../ResourceCreationTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs index 0937313f1..58331c992 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs @@ -22,4 +22,176 @@ public void CanCreateTheCollectorResource() 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); + } } From abd19d4bcb69bdc9b42685da29a15e4572df5b1b Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 01:42:49 +0000 Subject: [PATCH 17/22] object has a null guard earlier so we don't have to force it --- .../OpenTelemetryCollectorExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index 34bdcec5e..aeb037a7d 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -153,8 +153,8 @@ private static IResourceBuilder AddEnvironmentVa return Task.CompletedTask; } - var grpcEndpoint = collectorResource.GetEndpoint(collectorResource!.GrpcEndpoint.EndpointName); - var httpEndpoint = collectorResource.GetEndpoint(collectorResource!.HttpEndpoint.EndpointName); + var grpcEndpoint = collectorResource.GetEndpoint(collectorResource.GrpcEndpoint.EndpointName); + var httpEndpoint = collectorResource.GetEndpoint(collectorResource.HttpEndpoint.EndpointName); if (!resources.Any()) { From 446c6a8b7875e1ea034a1203fb845231e8f68186 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 01:43:16 +0000 Subject: [PATCH 18/22] Fixing a CA1853 - Dictionary.Remove does its own guard check --- .../OpenTelemetryCollectorExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index aeb037a7d..55285aab8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -178,8 +178,7 @@ private static IResourceBuilder AddEnvironmentVa return; } - if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT")) - context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); + context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT"); context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url); })); } From 53e227017d6c5097140adb76eb0ac8fc7b6e6e1d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 02:36:19 +0000 Subject: [PATCH 19/22] Tightening up resource access --- .../OpenTelemetryCollectorExtensions.cs | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs index 55285aab8..ecdcdd62e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorExtensions.cs @@ -113,18 +113,12 @@ public static IResourceBuilder WithConfig(this I /// private static IResourceBuilder WithFirstStartup(this IResourceBuilder builder) { - builder.OnBeforeResourceStarted((resource, beforeStartedEvent, cancellationToken) => + builder.OnBeforeResourceStarted((collectorResource, beforeStartedEvent, cancellationToken) => { - var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(resource); + var logger = beforeStartedEvent.Services.GetRequiredService().GetLogger(collectorResource); var appModel = beforeStartedEvent.Services.GetRequiredService(); var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); - if (collectorResource is null) - { - logger.LogWarning("No collector resource found"); - return Task.CompletedTask; - } foreach (var resourceItem in resources.Where(r => r.HasAnnotationOfType())) { resourceItem.Annotations.Add(new WaitAnnotation(collectorResource, WaitType.WaitUntilHealthy)); @@ -140,18 +134,11 @@ private static IResourceBuilder WithFirstStartup /// private static IResourceBuilder AddEnvironmentVariablesEventHook(this IResourceBuilder builder) { - builder.OnResourceEndpointsAllocated((resource, allocatedEvent, cancellationToken) => + builder.OnResourceEndpointsAllocated((collectorResource, allocatedEvent, cancellationToken) => { - var logger = allocatedEvent.Services.GetRequiredService().GetLogger(resource); + var logger = allocatedEvent.Services.GetRequiredService().GetLogger(collectorResource); var appModel = allocatedEvent.Services.GetRequiredService(); var resources = appModel.GetProjectResources(); - var collectorResource = appModel.Resources.OfType().FirstOrDefault(); - - if (collectorResource is null) - { - logger.LogWarning("No collector resource found"); - return Task.CompletedTask; - } var grpcEndpoint = collectorResource.GetEndpoint(collectorResource.GrpcEndpoint.EndpointName); var httpEndpoint = collectorResource.GetEndpoint(collectorResource.HttpEndpoint.EndpointName); From 760a610294f3a45132bcd6fafa298e9a762a6f3f Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 04:44:29 +0000 Subject: [PATCH 20/22] Expanding the unit tests --- src/Shared/DevCertHostingExtensions.cs | 6 +- ...osting.OpenTelemetryCollector.Tests.csproj | 1 + .../ResourceCreationTests.cs | 453 ++++++++++++++++++ 3 files changed, 456 insertions(+), 4 deletions(-) diff --git a/src/Shared/DevCertHostingExtensions.cs b/src/Shared/DevCertHostingExtensions.cs index 76dabc080..f62539822 100644 --- a/src/Shared/DevCertHostingExtensions.cs +++ b/src/Shared/DevCertHostingExtensions.cs @@ -45,7 +45,7 @@ public static IResourceBuilder RunWithHttpsDevCertificate( { 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"); @@ -58,7 +58,7 @@ public static IResourceBuilder RunWithHttpsDevCertificate( var existingResource = builder.ApplicationBuilder.Resources.FirstOrDefault(r => r.Name == exportResourceName); IResourceBuilder exportExecutable; - if (existingResource == null) + if (existingResource is null) { // Create the executable resource to export the certificate exportExecutable = builder.ApplicationBuilder @@ -85,8 +85,6 @@ public static IResourceBuilder RunWithHttpsDevCertificate( // 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}"; 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 index 97b4b69ed..001ef9dfa 100644 --- 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 @@ -6,6 +6,7 @@ + diff --git a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs index 58331c992..ee24c94ca 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/ResourceCreationTests.cs @@ -1,4 +1,7 @@ using Aspire.Hosting; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests; @@ -194,4 +197,454 @@ public void CanForceNonSSLForTheCollector() 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); + } } From 0c69e5864a494ea35317f387c51fa4dcc1d20f78 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 05:05:06 +0000 Subject: [PATCH 21/22] More tests --- .../RoutingExtensionTests.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.Tests/RoutingExtensionTests.cs 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 From 52f11597ff32f4120e8e77665487d5e45d5be999 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 4 Sep 2025 23:30:50 +0000 Subject: [PATCH 22/22] Adding martinjt as codeowner --- CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) 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