Skip to content

Commit 7ac9a9d

Browse files
committed
Add OpenTelemetry Collector extension/component
1 parent 7690e5f commit 7ac9a9d

9 files changed

+396
-0
lines changed

CommunityToolkit.Aspire.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Aspire.Hos
361361
EndProject
362362
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}"
363363
EndProject
364+
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}"
365+
EndProject
364366
Global
365367
GlobalSection(SolutionConfigurationPlatforms) = preSolution
366368
Debug|Any CPU = Debug|Any CPU
@@ -947,6 +949,10 @@ Global
947949
{39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Debug|Any CPU.Build.0 = Debug|Any CPU
948950
{39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Release|Any CPU.ActiveCfg = Release|Any CPU
949951
{39A6C03B-52AB-45F4-8D01-C3A1E5095765}.Release|Any CPU.Build.0 = Release|Any CPU
952+
{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
953+
{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
954+
{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
955+
{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C}.Release|Any CPU.Build.0 = Release|Any CPU
950956
EndGlobalSection
951957
GlobalSection(SolutionProperties) = preSolution
952958
HideSolutionNode = FALSE
@@ -1128,6 +1134,7 @@ Global
11281134
{92D490BC-B953-45DC-8F9D-A992B2AEF96A} = {41ACF613-EE5A-5900-F4D1-9FB713A32BE8}
11291135
{27144ED2-9F74-4A86-AABA-38CD061D8984} = {41ACF613-EE5A-5900-F4D1-9FB713A32BE8}
11301136
{39A6C03B-52AB-45F4-8D01-C3A1E5095765} = {E3C2B4B7-B3B0-4E7F-A975-A6C7FD926792}
1137+
{2A57ADE4-CEC4-418C-8479-AFE8134EEB0C} = {414151D4-7009-4E78-A5C6-D99EBD1E67D1}
11311138
EndGlobalSection
11321139
GlobalSection(ExtensibilityGlobals) = postSolution
11331140
SolutionGuid = {08B1D4B8-D2C5-4A64-BB8B-E1A2B29525F0}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using Aspire.Hosting.ApplicationModel;
2+
using Aspire.Hosting.Lifecycle;
3+
using CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.Hosting;
6+
7+
namespace Aspire.Hosting;
8+
9+
/// <summary>
10+
/// Extensions for adding OpenTelemetry Collector to the Aspire AppHost
11+
/// </summary>
12+
public static class CollectorExtensions
13+
{
14+
private const string DashboardOtlpUrlVariableName = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
15+
private const string DashboardOtlpApiKeyVariableName = "AppHost:OtlpApiKey";
16+
private const string DashboardOtlpUrlDefaultValue = "http://localhost:18889";
17+
18+
/// <summary>
19+
/// Adds an OpenTelemetry Collector into the Aspire AppHost
20+
/// </summary>
21+
/// <param name="builder">The builder</param>
22+
/// <param name="name">The name of the collector</param>
23+
/// <param name="settings">The settings for the collector</param>
24+
/// <returns></returns>
25+
public static IResourceBuilder<CollectorResource> AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder,
26+
string name,
27+
OpenTelemetryCollectorSettings settings)
28+
{
29+
var url = builder.Configuration[DashboardOtlpUrlVariableName] ?? DashboardOtlpUrlDefaultValue;
30+
var isHttpsEnabled = url.StartsWith("https", StringComparison.OrdinalIgnoreCase);
31+
32+
var dashboardOtlpEndpoint = ReplaceLocalhostWithContainerHost(url, builder.Configuration);
33+
34+
var resource = new CollectorResource(name);
35+
var resourceBuilder = builder.AddResource(resource)
36+
.WithImage(settings.CollectorImage, settings.CollectorVersion)
37+
.WithEndpoint(port: 4317, targetPort: 4317, name: CollectorResource.GRPCEndpointName, scheme: "http")
38+
.WithEndpoint(port: 4318, targetPort: 4318, name: CollectorResource.HTTPEndpointName, scheme: "http")
39+
.WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint)
40+
.WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]);
41+
42+
43+
if (isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment())
44+
{
45+
DevCertHostingExtensions.RunWithHttpsDevCertificate(resourceBuilder, "HTTPS_CERT_FILE", "HTTPS_CERT_KEY_FILE", (certFilePath, certKeyPath) =>
46+
{
47+
// Set TLS details using YAML path via the command line. This allows the values to be added to the existing config file.
48+
// 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.
49+
resourceBuilder.WithArgs(
50+
$@"--config=yaml:${settings.CertificateFileLocator}: ""dev-certs/dev-cert.pem""",
51+
$@"--config=yaml:${settings.KeyFileLocator}: ""dev-certs/dev-cert.key""");
52+
});
53+
}
54+
55+
return resourceBuilder;
56+
}
57+
58+
/// <summary>
59+
/// Force all apps to forward to the collector instead of the dashboard directly
60+
/// </summary>
61+
/// <param name="builder"></param>
62+
/// <returns></returns>
63+
public static IResourceBuilder<CollectorResource> WithAppForwarding(this IResourceBuilder<CollectorResource> builder)
64+
{
65+
builder.ApplicationBuilder.Services.TryAddLifecycleHook<EnvironmentVariableHook>();
66+
return builder;
67+
}
68+
69+
private static string ReplaceLocalhostWithContainerHost(string value, IConfiguration configuration)
70+
{
71+
var hostName = configuration["AppHost:ContainerHostname"] ?? "host.docker.internal";
72+
73+
return value.Replace("localhost", hostName, StringComparison.OrdinalIgnoreCase)
74+
.Replace("127.0.0.1", hostName)
75+
.Replace("[::1]", hostName);
76+
}
77+
78+
/// <summary>
79+
/// Adds an Additional config file to the collector
80+
/// </summary>
81+
/// <param name="builder"></param>
82+
/// <param name="configPath"></param>
83+
/// <returns></returns>
84+
public static IResourceBuilder<CollectorResource> AddConfig(this IResourceBuilder<CollectorResource> builder, string configPath)
85+
{
86+
var configFileInfo = new FileInfo(configPath);
87+
return builder.WithBindMount(configPath, $"/config/{configFileInfo.Name}")
88+
.WithArgs($"--config=/config/{configFileInfo.Name}");
89+
}
90+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Aspire.Hosting.ApplicationModel;
2+
3+
namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector;
4+
5+
/// <summary>
6+
/// Represents an OpenTelemetry Collector resource
7+
/// </summary>
8+
/// <param name="name"></param>
9+
public class CollectorResource(string name) : ContainerResource(name)
10+
{
11+
internal static string GRPCEndpointName = "grpc";
12+
internal static string HTTPEndpointName = "http";
13+
14+
internal EndpointReference GRPCEndpoint => new(this, GRPCEndpointName);
15+
16+
internal EndpointReference HTTPEndpoint => new(this, HTTPEndpointName);
17+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Aspire.Hosting" />
11+
</ItemGroup>
12+
</Project>
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using System.Diagnostics;
2+
using Aspire.Hosting.ApplicationModel;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Aspire.Hosting;
8+
9+
internal static class DevCertHostingExtensions
10+
{
11+
/// <summary>
12+
/// Injects the ASP.NET Core HTTPS developer certificate into the resource via the specified environment variables when
13+
/// <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/>
14+
/// If the resource is a <see cref="ContainerResource"/>, the certificate files will be bind mounted into the container.
15+
/// </summary>
16+
/// <remarks>
17+
/// This method <strong>does not</strong> configure an HTTPS endpoint on the resource.
18+
/// Use <see cref="ResourceBuilderExtensions.WithHttpsEndpoint{TResource}"/> to configure an HTTPS endpoint.
19+
/// </remarks>
20+
public static IResourceBuilder<TResource> RunWithHttpsDevCertificate<TResource>(
21+
this IResourceBuilder<TResource> builder, string certFileEnv, string certKeyFileEnv, Action<string, string>? onSuccessfulExport = null)
22+
where TResource : IResourceWithEnvironment
23+
{
24+
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode && builder.ApplicationBuilder.Environment.IsDevelopment())
25+
{
26+
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>(async (e, ct) =>
27+
{
28+
var logger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource);
29+
30+
// Export the ASP.NET Core HTTPS development certificate & private key to files and configure the resource to use them via
31+
// the specified environment variables.
32+
var (exported, certPath, certKeyPath) = await TryExportDevCertificateAsync(builder.ApplicationBuilder, logger);
33+
34+
if (!exported)
35+
{
36+
// The export failed for some reason, don't configure the resource to use the certificate.
37+
return;
38+
}
39+
40+
if (builder.Resource is ContainerResource containerResource)
41+
{
42+
// Bind-mount the certificate files into the container.
43+
const string DEV_CERT_BIND_MOUNT_DEST_DIR = "/dev-certs";
44+
45+
var certFileName = Path.GetFileName(certPath);
46+
var certKeyFileName = Path.GetFileName(certKeyPath);
47+
48+
var bindSource = Path.GetDirectoryName(certPath) ?? throw new UnreachableException();
49+
50+
var certFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certFileName}";
51+
var certKeyFileDest = $"{DEV_CERT_BIND_MOUNT_DEST_DIR}/{certKeyFileName}";
52+
53+
builder.ApplicationBuilder.CreateResourceBuilder(containerResource)
54+
.WithBindMount(bindSource, DEV_CERT_BIND_MOUNT_DEST_DIR, isReadOnly: true)
55+
.WithEnvironment(certFileEnv, certFileDest)
56+
.WithEnvironment(certKeyFileEnv, certKeyFileDest);
57+
}
58+
else
59+
{
60+
builder
61+
.WithEnvironment(certFileEnv, certPath)
62+
.WithEnvironment(certKeyFileEnv, certKeyPath);
63+
}
64+
65+
if (onSuccessfulExport is not null)
66+
{
67+
onSuccessfulExport(certPath, certKeyPath);
68+
}
69+
});
70+
}
71+
72+
return builder;
73+
}
74+
75+
private static async Task<(bool, string CertFilePath, string CertKeyFilPath)> TryExportDevCertificateAsync(IDistributedApplicationBuilder builder, ILogger logger)
76+
{
77+
// Exports the ASP.NET Core HTTPS development certificate & private key to PEM files using 'dotnet dev-certs https' to a temporary
78+
// directory and returns the path.
79+
// 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.
80+
var appNameHash = builder.Configuration["AppHost:Sha256"]![..10];
81+
var tempDir = Path.Combine(Path.GetTempPath(), $"aspire.{appNameHash}");
82+
var certExportPath = Path.Combine(tempDir, "dev-cert.pem");
83+
var certKeyExportPath = Path.Combine(tempDir, "dev-cert.key");
84+
85+
if (File.Exists(certExportPath) && File.Exists(certKeyExportPath))
86+
{
87+
// Certificate already exported, return the path.
88+
logger.LogDebug("Using previously exported dev cert files '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
89+
return (true, certExportPath, certKeyExportPath);
90+
}
91+
92+
if (File.Exists(certExportPath))
93+
{
94+
logger.LogTrace("Deleting previously exported dev cert file '{CertPath}'", certExportPath);
95+
File.Delete(certExportPath);
96+
}
97+
98+
if (File.Exists(certKeyExportPath))
99+
{
100+
logger.LogTrace("Deleting previously exported dev cert key file '{CertKeyPath}'", certKeyExportPath);
101+
File.Delete(certKeyExportPath);
102+
}
103+
104+
if (!Directory.Exists(tempDir))
105+
{
106+
logger.LogTrace("Creating directory to export dev cert to '{ExportDir}'", tempDir);
107+
Directory.CreateDirectory(tempDir);
108+
}
109+
110+
string[] args = ["dev-certs", "https", "--export-path", $"\"{certExportPath}\"", "--format", "Pem", "--no-password"];
111+
var argsString = string.Join(' ', args);
112+
113+
logger.LogTrace("Running command to export dev cert: {ExportCmd}", $"dotnet {argsString}");
114+
var exportStartInfo = new ProcessStartInfo
115+
{
116+
FileName = "dotnet",
117+
Arguments = argsString,
118+
RedirectStandardOutput = true,
119+
RedirectStandardError = true,
120+
UseShellExecute = false,
121+
CreateNoWindow = true,
122+
WindowStyle = ProcessWindowStyle.Hidden,
123+
};
124+
125+
var exportProcess = new Process { StartInfo = exportStartInfo };
126+
127+
Task? stdOutTask = null;
128+
Task? stdErrTask = null;
129+
130+
try
131+
{
132+
try
133+
{
134+
if (exportProcess.Start())
135+
{
136+
stdOutTask = ConsumeOutput(exportProcess.StandardOutput, msg => logger.LogInformation("> {StandardOutput}", msg));
137+
stdErrTask = ConsumeOutput(exportProcess.StandardError, msg => logger.LogError("! {ErrorOutput}", msg));
138+
}
139+
}
140+
catch (Exception ex)
141+
{
142+
logger.LogError(ex, "Failed to start HTTPS dev certificate export process");
143+
return default;
144+
}
145+
146+
var timeout = TimeSpan.FromSeconds(5);
147+
var exited = exportProcess.WaitForExit(timeout);
148+
149+
if (exited && File.Exists(certExportPath) && File.Exists(certKeyExportPath))
150+
{
151+
logger.LogDebug("Dev cert exported to '{CertPath}' and '{CertKeyPath}'", certExportPath, certKeyExportPath);
152+
return (true, certExportPath, certKeyExportPath);
153+
}
154+
155+
if (exportProcess.HasExited && exportProcess.ExitCode != 0)
156+
{
157+
logger.LogError("HTTPS dev certificate export failed with exit code {ExitCode}", exportProcess.ExitCode);
158+
}
159+
else if (!exportProcess.HasExited)
160+
{
161+
exportProcess.Kill(true);
162+
logger.LogError("HTTPS dev certificate export timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds);
163+
}
164+
else
165+
{
166+
logger.LogError("HTTPS dev certificate export failed for an unknown reason");
167+
}
168+
return default;
169+
}
170+
finally
171+
{
172+
await Task.WhenAll(stdOutTask ?? Task.CompletedTask, stdErrTask ?? Task.CompletedTask);
173+
}
174+
175+
static async Task ConsumeOutput(TextReader reader, Action<string> callback)
176+
{
177+
char[] buffer = new char[256];
178+
int charsRead;
179+
180+
while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
181+
{
182+
callback(new string(buffer, 0, charsRead));
183+
}
184+
}
185+
}
186+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using Aspire.Hosting.ApplicationModel;
2+
using Aspire.Hosting.Lifecycle;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector;
6+
7+
internal class EnvironmentVariableHook : IDistributedApplicationLifecycleHook
8+
{
9+
private readonly ILogger<EnvironmentVariableHook> _logger;
10+
11+
public EnvironmentVariableHook(ILogger<EnvironmentVariableHook> logger)
12+
{
13+
_logger = logger;
14+
}
15+
public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
16+
{
17+
var resources = appModel.GetProjectResources();
18+
var collectorResource = appModel.Resources.OfType<CollectorResource>().FirstOrDefault();
19+
20+
if (collectorResource == null)
21+
{
22+
_logger.LogWarning("No collector resource found");
23+
return Task.CompletedTask;
24+
}
25+
26+
var endpoint = collectorResource!.GetEndpoint(collectorResource!.GRPCEndpoint.EndpointName);
27+
if (endpoint == null)
28+
{
29+
_logger.LogWarning("No endpoint for the collector");
30+
return Task.CompletedTask;
31+
}
32+
33+
if (resources.Count() == 0)
34+
{
35+
_logger.LogInformation("No resources to add Environment Variables to");
36+
}
37+
38+
foreach (var resourceItem in resources)
39+
{
40+
_logger.LogDebug($"Forwarding Telemetry for {resourceItem.Name} to the collector");
41+
if (resourceItem == null) continue;
42+
43+
resourceItem.Annotations.Add(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) =>
44+
{
45+
if (context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT"))
46+
context.EnvironmentVariables.Remove("OTEL_EXPORTER_OTLP_ENDPOINT");
47+
context.EnvironmentVariables.Add("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint.Url);
48+
}));
49+
}
50+
51+
return Task.CompletedTask;
52+
}
53+
}

0 commit comments

Comments
 (0)