Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
Next Next commit
Added OpenTelemetry Collector component
  • Loading branch information
martinjt committed Aug 29, 2025
commit ef60fdf0a466852aa369c4da0021828df6f7941b
1 change: 1 addition & 0 deletions CommunityToolkit.Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
<Project Path="src/CommunityToolkit.Aspire.Hosting.Ngrok/CommunityToolkit.Aspire.Hosting.Ngrok.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.NodeJS.Extensions/CommunityToolkit.Aspire.Hosting.NodeJS.Extensions.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Ollama/CommunityToolkit.Aspire.Hosting.Ollama.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector/CommunityToolkit.Aspire.Hosting.OpenTelemetryCollector.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.PapercutSmtp/CommunityToolkit.Aspire.Hosting.PapercutSmtp.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.PowerShell/CommunityToolkit.Aspire.Hosting.PowerShell.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

namespace Aspire.Hosting;

/// <summary>
/// Extension methods to add the collector resource
/// </summary>
public static class CollectorExtensions
{
private const string DashboardOtlpUrlVariableNameLegacy = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
private const string DashboardOtlpUrlVariableName = "ASPIRE_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"></param>
/// <param name="name"></param>
/// <param name="configureSettings"></param>
/// <returns></returns>
public static IResourceBuilder<CollectorResource> AddOpenTelemetryCollector(this IDistributedApplicationBuilder builder,
string name,
Action<OpenTelemetryCollectorSettings> configureSettings = null!)
{
var url = builder.Configuration[DashboardOtlpUrlVariableName] ??
builder.Configuration[DashboardOtlpUrlVariableNameLegacy] ??
DashboardOtlpUrlDefaultValue;

var settings = new OpenTelemetryCollectorSettings();
configureSettings?.Invoke(settings);

var isHttpsEnabled = !settings.ForceNonSecureReceiver && 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)
.WithEnvironment("ASPIRE_ENDPOINT", dashboardOtlpEndpoint)
.WithEnvironment("ASPIRE_API_KEY", builder.Configuration[DashboardOtlpApiKeyVariableName]);

if (settings.EnableGrpcEndpoint)
resourceBuilder.WithEndpoint(targetPort: 4317, name: CollectorResource.GRPCEndpointName, scheme: isHttpsEnabled ? "https" : "http");
if (settings.EnableHttpEndpoint)
resourceBuilder.WithEndpoint(targetPort: 4318, name: CollectorResource.HTTPEndpointName, scheme: isHttpsEnabled ? "https" : "http");


if (!settings.ForceNonSecureReceiver && isHttpsEnabled && builder.ExecutionContext.IsRunMode && builder.Environment.IsDevelopment())
{
DevCertHostingExtensions.RunWithHttpsDevCertificate(resourceBuilder, "HTTPS_CERT_FILE", "HTTPS_CERT_KEY_FILE", (certFilePath, certKeyPath) =>
{
if (settings.EnableHttpEndpoint)
{
resourceBuilder.WithArgs(
$@"--config=yaml:receivers::otlp::protocols::http::tls::cert_file: ""{certFilePath}""",
$@"--config=yaml:receivers::otlp::protocols::http::tls::key_file: ""{certKeyPath}""");
}
if (settings.EnableGrpcEndpoint)
{
resourceBuilder.WithArgs(
$@"--config=yaml:receivers::otlp::protocols::grpc::tls::cert_file: ""{certFilePath}""",
$@"--config=yaml:receivers::otlp::protocols::grpc::tls::key_file: ""{certKeyPath}""");
}
});
}
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 a config file to the collector
/// </summary>
/// <param name="builder"></param>
/// <param name="configPath"></param>
/// <returns></returns>
public static IResourceBuilder<CollectorResource> WithConfig(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,23 @@
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting;

/// <summary>
/// The collector resource
/// </summary>
/// <param name="name">Name of the resource</param>
public class CollectorResource(string name) : ContainerResource(name)
{
internal static string GRPCEndpointName = "grpc";
internal static string HTTPEndpointName = "http";

/// <summary>
/// gRPC Endpoint
/// </summary>
public EndpointReference GRPCEndpoint => new(this, GRPCEndpointName);

/// <summary>
/// HTTP Endpoint
/// </summary>
public EndpointReference HTTPEndpoint => new(this, HTTPEndpointName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>An Aspire component to add an OpenTelemetry Collector into the OTLP pipeline</Description>
<AdditionalPackageTags>opentelemetry observability</AdditionalPackageTags>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting;

/// <summary>
/// Hooks to add the OTLP environment variables to the various containers
/// </summary>
/// <param name="logger"></param>
public class EnvironmentVariableHook(ILogger<EnvironmentVariableHook> logger) : IDistributedApplicationLifecycleHook
{
/// <inheritdoc />
public Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
{
var resources = appModel.GetProjectResources();
var collectorResource = appModel.Resources.OfType<CollectorResource>().FirstOrDefault();

if (collectorResource is null)
{
logger.LogWarning("No collector resource found");
return Task.CompletedTask;
}

var grpcEndpoint = collectorResource!.GetEndpoint(collectorResource!.GRPCEndpoint.EndpointName);
var httpEndpoint = collectorResource!.GetEndpoint(collectorResource!.HTTPEndpoint.EndpointName);

if (!resources.Any())
{
logger.LogInformation("No resources to add Environment Variables to");
}

foreach (var resourceItem in resources)
{
logger.LogDebug("Forwarding Telemetry for {name} to the collector", resourceItem.Name);
if (resourceItem is null) continue;

resourceItem.Annotations.Add(new EnvironmentCallbackAnnotation((EnvironmentCallbackContext context) =>
{
var protocol = context.EnvironmentVariables.GetValueOrDefault("OTEL_EXPORTER_OTLP_PROTOCOL", "");
var endpoint = protocol.ToString() == "http/protobuf" ? httpEndpoint : grpcEndpoint;

if (endpoint == null)
{
logger.LogWarning("No {protocol} endpoint on the collector for {resourceName} to use",
protocol, resourceItem.Name);
return;
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Aspire.Hosting;

/// <summary>
/// Settings for the OpenTelemetry Collector
/// </summary>
public class OpenTelemetryCollectorSettings
{
/// <summary>
/// The version of the collector, defaults to latest
/// </summary>
public string CollectorVersion { get; set; } = "latest";

/// <summary>
/// The image of the collector, defaults to ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib
/// </summary>
public string CollectorImage { get; set; } = "ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib";

/// <summary>
/// Force the default OTLP receivers in the collector to use HTTP even if Aspire is set to HTTPS
/// </summary>
public bool ForceNonSecureReceiver { get; set; } = false;

/// <summary>
/// Enable the gRPC endpoint on the collector container (requires the relevant collector config)
///
/// Note: this will also setup SSL if Aspire is configured for HTTPS
/// </summary>
public bool EnableGrpcEndpoint { get; set; } = true;

/// <summary>
/// Enable the HTTP endpoint on the collector container (requires the relevant collector config)
///
/// Note: this will also setup SSL if Aspire is configured for HTTPS
/// </summary>
public bool EnableHttpEndpoint { get; set; } = true;
}
Loading