Skip to content

Add OpenTelemetry Collector extension/component #603

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CommunityToolkit.Aspire.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Extensions for adding OpenTelemetry Collector to the Aspire AppHost
/// </summary>
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";

/// <summary>
/// Adds an OpenTelemetry Collector into the Aspire AppHost
/// </summary>
/// <param name="builder">The builder</param>
/// <param name="name">The name of the collector</param>
/// <param name="settings">The settings for the collector</param>
/// <returns></returns>
public static IResourceBuilder<CollectorResource> 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;
}

/// <summary>
/// Force all apps to forward to the collector instead of the dashboard directly
/// </summary>
/// <param name="builder"></param>
/// <returns></returns>
public static IResourceBuilder<CollectorResource> WithAppForwarding(this IResourceBuilder<CollectorResource> builder)
{
builder.ApplicationBuilder.Services.TryAddLifecycleHook<EnvironmentVariableHook>();
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);
}

/// <summary>
/// Adds an Additional config file to the collector
/// </summary>
/// <param name="builder"></param>
/// <param name="configPath"></param>
/// <returns></returns>
public static IResourceBuilder<CollectorResource> AddConfig(this IResourceBuilder<CollectorResource> builder, string configPath)
{
var configFileInfo = new FileInfo(configPath);
return builder.WithBindMount(configPath, $"/config/{configFileInfo.Name}")
.WithArgs($"--config=/config/{configFileInfo.Name}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Aspire.Hosting.ApplicationModel;

namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector;

/// <summary>
/// Represents an OpenTelemetry Collector resource
/// </summary>
/// <param name="name"></param>
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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

Comment on lines +3 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

This will all be provided by the props files above.

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
</Project>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can likely use this in a few places, it should help unblock #388 and #444, so could it go into the Shared folder and be added to the csproj.

Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
/// <paramref name="builder"/>.<see cref="IResourceBuilder{T}.ApplicationBuilder">ApplicationBuilder</see>.<see cref="IDistributedApplicationBuilder.ExecutionContext">ExecutionContext</see>.<see cref="DistributedApplicationExecutionContext.IsRunMode">IsRunMode</see><c> == true</c>.<br/>
/// If the resource is a <see cref="ContainerResource"/>, the certificate files will be bind mounted into the container.
/// </summary>
/// <remarks>
/// This method <strong>does not</strong> configure an HTTPS endpoint on the resource.
/// Use <see cref="ResourceBuilderExtensions.WithHttpsEndpoint{TResource}"/> to configure an HTTPS endpoint.
/// </remarks>
public static IResourceBuilder<TResource> RunWithHttpsDevCertificate<TResource>(
this IResourceBuilder<TResource> builder, string certFileEnv, string certKeyFileEnv, Action<string, string>? onSuccessfulExport = null)
where TResource : IResourceWithEnvironment
{
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment())
{
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>(async (e, ct) =>
{
var logger = e.Services.GetRequiredService<ResourceLoggerService>().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<string> callback)
{
char[] buffer = new char[256];
int charsRead;

while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
callback(new string(buffer, 0, charsRead));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<EnvironmentVariableHook> _logger;

public EnvironmentVariableHook(ILogger<EnvironmentVariableHook> logger)
{
_logger = logger;
}
Comment on lines +11 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a primary constructor

public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
{
var resources = appModel.GetProjectResources();
var collectorResource = appModel.Resources.OfType<CollectorResource>().FirstOrDefault();

if (collectorResource == null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (collectorResource == null)
if (collectorResource is 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (resources.Count() == 0)
if (!resources.Any())

{
_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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (resourceItem == null) continue;
if (resourceItem is 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;
}
}
Loading