-
Notifications
You must be signed in to change notification settings - Fork 87
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="Aspire.Hosting" /> | ||
</ItemGroup> | ||
</Project> |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
{ | ||||||
_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) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
{ | ||||||
_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; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
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; | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will all be provided by the props files above.