diff --git a/CommunityToolkit.Aspire.sln b/CommunityToolkit.Aspire.sln index b05e9c6b..139aceca 100644 --- a/CommunityToolkit.Aspire.sln +++ b/CommunityToolkit.Aspire.sln @@ -361,6 +361,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hos EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.Azure.Dapr.AppHost", "examples\dapr\CommunityToolkit.Aspire.Hosting.Azure.Dapr.AppHost\CommunityToolkit.Aspire.Hosting.Azure.Dapr.AppHost.csproj", "{39A6C03B-52AB-45F4-8D01-C3A1E5095765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector", "src\CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector\CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj", "{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -947,6 +949,10 @@ Global {39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Debug|Any CPU.Build.0 = Debug|Any CPU {39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Release|Any CPU.ActiveCfg = Release|Any CPU {39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Release|Any CPU.Build.0 = Release|Any CPU + {2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1128,6 +1134,7 @@ Global {92D490BC-B953-45DC-8F9D-A992B2AEF96A} = {41ACF613-EE5A-5900-F4D1-9FB713A32BE8} {27144ED2-9F74-4A86-AABA-38CD061D8984} = {41ACF613-EE5A-5900-F4D1-9FB713A32BE8} {39A6C03B-52AB-45F4-8D01-C3A1E5095765} = {E3C2B4B7-B3B0-4E7F-A975-A6C7FD926792} + {2A57ADE4-CEC4-418C-8479-AFE8134EEB0C} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {08B1D4B8-D2C5-4A64-BB8B-E1A2B29525F0} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs new file mode 100644 index 00000000..e91fafb7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorExtensions.cs @@ -0,0 +1,90 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Hosting; + +/// +/// Extensions for adding OpenTelemetry Collector to the Aspire AppHost +/// +public static class CollectorExtensions +{ + private const string DashboardOtlpUrlVariableName = "DOTNET_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 + /// + /// The builder + /// The name of the collector + /// The settings for the collector + /// + public static IResourceBuilder AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder, + string name, + OpenTelemetryCollectorSettings settings) + { + var url = builder.Configuration[DashboardOtlpUrlVariableName] ?? DashboardOtlpUrlDefaultValue; + var isHttpsEnabled = 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) + .WithEndpoint(port: 4317, targetPort: 4317, name: CollectorResource.GRPCEndpointName, scheme: "http") + .WithEndpoint(port: 4318, targetPort: 4318, name: CollectorResource.HTTPEndpointName, scheme: "http") + .WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]); + + + if (isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment()) + { + DevCertHostingExtensions.RunWithHttpsDevCertificate(resourceBuilder, "HTTPS_CERT_FILE", "HTTPS_CERT_KEY_FILE", (certFilePath, certKeyPath) => + { + // Set TLS details using YAML path via the command line. This allows the values to be added to the existing config file. + // Setting the values in the config file doesn't work because adding the "tls" section always enables TLS, even if there is no cert provided. + resourceBuilder.WithArgs( + $@"--config=yaml:${settings.CertificateFileLocator}: ""dev-certs/dev-cert.pem""", + $@"--config=yaml:${settings.KeyFileLocator}: ""dev-certs/dev-cert.key"""); + }); + } + + 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 an Additional config file to the collector + /// + /// + /// + /// + public static IResourceBuilder AddConfig(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 00000000..71d8f180 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CollectorResource.cs @@ -0,0 +1,17 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector; + +/// +/// Represents an OpenTelemetry Collector resource +/// +/// +public class CollectorResource(string name) : ContainerResource(name) +{ + internal static string GRPCEndpointName = "grpc"; + internal static string HTTPEndpointName = "http"; + + internal EndpointReference GRPCEndpoint => new(this, GRPCEndpointName); + + internal 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 00000000..6c64a4a5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + enable + enable + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/DevCertHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/DevCertHostingExtensions.cs new file mode 100644 index 00000000..018b373a --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/DevCertHostingExtensions.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal 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; + } + + 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(); + + var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}"; + var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}"; + + builder.ApplicationBuilder.CreateResourceBuilder(containerResource) + .WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: true) + .WithEnvironment(certFileEnv, certFileDest) + .WithEnvironment(certKeyFileEnv, certKeyFileDest); + } + else + { + builder + .WithEnvironment(certFileEnv, certPath) + .WithEnvironment(certKeyFileEnv, certKeyPath); + } + + if (onSuccessfulExport is not null) + { + onSuccessfulExport(certPath, certKeyPath); + } + }); + } + + 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 diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs new file mode 100644 index 00000000..cf87845d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/EnvironmentVariableHook.cs @@ -0,0 +1,53 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector; + +internal class EnvironmentVariableHook : IDistributedApplicationLifecycleHook +{ + private readonly ILogger _logger; + + public EnvironmentVariableHook(ILogger logger) + { + _logger = logger; + } + public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken) + { + var resources = appModel.GetProjectResources(); + var collectorResource = appModel.Resources.OfType().FirstOrDefault(); + + if (collectorResource == null) + { + _logger.LogWarning("No collector resource found"); + return Task.CompletedTask; + } + + var endpoint = collectorResource!.GetEndpoint(collectorResource!.GRPCEndpoint.EndpointName); + if (endpoint == null) + { + _logger.LogWarning("No endpoint for the collector"); + return Task.CompletedTask; + } + + if (resources.Count() == 0) + { + _logger.LogInformation("No resources to add Environment Variables to"); + } + + foreach (var resourceItem in resources) + { + _logger.LogDebug($"Forwarding Telemetry for {resourceItem.Name} to the collector"); + if (resourceItem == null) continue; + + resourceItem.Annotations.Add(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) => + { + 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 00000000..02c8ef5f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/OpenTelemetryCollectorSettings.cs @@ -0,0 +1,27 @@ +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"; + + /// + /// The name of the collector, defaults to the default otlp receiver + /// + public string CertificateFileLocator { get; set; } = "receivers::otlp::protocols::grpc::tls::cert_file"; + + /// + /// The name of the collector, defaults to the default otlp receiver + /// + public string KeyFileLocator { get; set; } = "receivers::otlp::protocols::grpc::tls::key_file"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Shipped.txt b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Shipped.txt new file mode 100644 index 00000000..815c9200 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Unshipped.txt b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..16a2e342 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +Aspire.Hosting.CollectorExtensions +static Aspire.Hosting.CollectorExtensions.AddOpenTelemetryCollector(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string! configFileLocation, string! collectorVersion = "latest", string! collectorImage = "ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib") -> Aspire.Hosting.ApplicationModel.IResourceBuilder! \ No newline at end of file