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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
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>();
Copy link
Member

Choose a reason for hiding this comment

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

Lifecycle hooks are mostly considered legacy with the event system now in place. The hook should be replacable with the OnAfterEndpointsAllocated callback.

Copy link
Author

Choose a reason for hiding this comment

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

I'm trying to gather from the documentation whether the OnAfterEndpointsAllocated is resource specific, or part of the wider DistributedAppModel. My concern being that this needs to be applied after the Aspire code has added the dashboard environment variables.

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,16 @@
<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>

<ItemGroup>
<Compile Include="$(SharedDir)\DevCertHostingExtensions.cs" Link="Utils\DevCertHostingExtensions.cs" />
</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";
Copy link
Member

Choose a reason for hiding this comment

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

We should pin to a version so it's known stable per the package version

Copy link
Author

Choose a reason for hiding this comment

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

I'm not convinced that pinning is the right approach for that, especially since there isn't a v1 yet. It's generally accepted that people don't pin even in production.


/// <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";
Copy link
Member

Choose a reason for hiding this comment

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

I feel like this should be split to registry/image like we do with other container resources. And is there a reason you're using ghcr.io not Docker Hub?

Copy link
Author

Choose a reason for hiding this comment

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

ghcr is the recommended default since the limits went into dockerhub.

Do you have an example of the pattern other integrations use for the registry split.

Copy link
Member

Choose a reason for hiding this comment

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

public const string Registry = "docker.io";
public const string Image = "ollama/ollama";


/// <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