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