Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"applicationUrl": "https://localhost:17453;http://localhost:15453",
"environmentVariables": {
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21453",
"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:21454",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22453"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21453",
"DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:21454",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22453"
}
},
Expand All @@ -22,6 +23,7 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19453",
"DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:19454",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20453"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,26 +214,41 @@ private static IResourceBuilder<PerlAppResource> AddPerlAppCore(
break;
}

resourceBuilder.WithOtlpExporter();
resourceBuilder.WithOtlpExporterIfEndpointAvailable(OtlpProtocol.HttpJson);
resourceBuilder.WithRequiredCommand("perl", "https://www.perl.org/get.html");
resourceBuilder.WithRequiredCommand("cpan", "https://metacpan.org/pod/CPAN");

// Configure OpenTelemetry exporters using environment variables
// https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
// The Perl OpenTelemetry SDK defaults to JSON encoding when Google::ProtocolBuffers::Dynamic
// is not available. Using http/json avoids a silent protocol mismatch where the SDK sends
// JSON-encoded data with a protobuf content-type that the OTLP collector rejects.
// OpenTelemetry::Exporter::OTLP supports HTTP/JSON and HTTP/protobuf, not gRPC.
// HTTP/JSON avoids requiring Google::ProtocolBuffers::Dynamic for protobuf encoding, which can be difficult (though not impossible)to get on windows, specifically.
resourceBuilder.WithEnvironment(context =>
{
if (context.ExecutionContext.IsRunMode &&
!context.EnvironmentVariables.ContainsKey("OTEL_EXPORTER_OTLP_ENDPOINT"))
{
return;
}

context.EnvironmentVariables["OTEL_TRACES_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_LOGS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_METRICS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf";

context.EnvironmentVariables["OTEL_PERL_TRACES_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_PERL_LOGS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_PERL_METRICS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_PERL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf";
if (!context.ExecutionContext.IsRunMode)
{
context.EnvironmentVariables["OTEL_PERL_TRACES_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_PERL_LOGS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_PERL_METRICS_EXPORTER"] = "otlp";
return;
}

// OpenTelemetry::Common reads OTEL_PERL_* before OTEL_*.
// Resolve OTEL_PERL_* from final OTEL_* values so later standard overrides still apply.
// Explicit OTEL_PERL_* values added later replace these mirrors.
MirrorPerlOtelVariable(context, "OTEL_TRACES_EXPORTER", "OTEL_PERL_TRACES_EXPORTER");
MirrorPerlOtelVariable(context, "OTEL_LOGS_EXPORTER", "OTEL_PERL_LOGS_EXPORTER");
MirrorPerlOtelVariable(context, "OTEL_METRICS_EXPORTER", "OTEL_PERL_METRICS_EXPORTER");
MirrorPerlOtelVariable(context, "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_PERL_EXPORTER_OTLP_ENDPOINT");
MirrorPerlOtelVariable(context, "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_PERL_EXPORTER_OTLP_PROTOCOL");
MirrorPerlOtelVariable(context, "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_PERL_EXPORTER_OTLP_HEADERS");
});

if (builder.ExecutionContext.IsRunMode)
Expand Down Expand Up @@ -539,6 +554,14 @@ private static Task ConfigurePerlCertificateTrust(CertificateTrustConfigurationC
return Task.CompletedTask;
}

/// <summary>
/// Sets a Perl-specific OpenTelemetry variable to resolve from the final standard variable value.
/// </summary>
private static void MirrorPerlOtelVariable(EnvironmentCallbackContext context, string sourceName, string targetName)
{
context.EnvironmentVariables[targetName] = new PerlOtelEnvironmentVariableMirror(context.EnvironmentVariables, sourceName);
}

/// <summary>
/// Resolves the local::lib path from the resource's <see cref="PerlLocalLibAnnotation"/>,
/// returning <c>null</c> when no local::lib is configured.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Aspire.Hosting.ApplicationModel;

namespace CommunityToolkit.Aspire.Hosting.Perl;

/// <summary>
/// Resolves a Perl-specific OpenTelemetry variable from the final value of its standard variable.
/// </summary>
/// <param name="environmentVariables">The environment variables shared by the resource's environment callbacks.</param>
/// <param name="sourceName">The standard OpenTelemetry variable to read.</param>
internal sealed class PerlOtelEnvironmentVariableMirror(
IReadOnlyDictionary<string, object> environmentVariables,
string sourceName) : IValueProvider, IValueWithReferences
{
/// <inheritdoc />
public IEnumerable<object> References =>
environmentVariables.TryGetValue(sourceName, out var value) && !ReferenceEquals(value, this)
? [value]
: [];

/// <inheritdoc />
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default) =>
GetValueAsync(new ValueProviderContext(), cancellationToken);

/// <inheritdoc />
public async ValueTask<string?> GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken = default)
{
if (!environmentVariables.TryGetValue(sourceName, out var value) || ReferenceEquals(value, this))
{
return null;
}

return value switch
{
string s => s,
IValueProvider provider => await provider.GetValueAsync(context, cancellationToken).ConfigureAwait(false),
IResourceBuilder<IResource> { Resource: IValueProvider provider } =>
await provider.GetValueAsync(context, cancellationToken).ConfigureAwait(false),
_ => value.ToString()
};
}

/// <inheritdoc />
public override string ToString() => sourceName;
}
17 changes: 17 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Perl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,23 @@ are supported. Certificate trust remains opt-in.

---

## OpenTelemetry

Perl resources use OTLP over HTTP with JSON encoding (`http/json`). Perl's OTLP exporter does not
support OTLP over gRPC, and JSON avoids requiring `Google::ProtocolBuffers::Dynamic`.

Configure the dashboard's HTTP OTLP endpoint in run mode, for example with
`ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL` or `DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL`.
When only the gRPC endpoint is configured, Aspire omits Perl OTLP exporter settings rather than
sending Perl telemetry to the gRPC listener.

The Perl SDK reads `OTEL_PERL_*` variables before standard `OTEL_*` variables. In run mode, the
integration resolves the Perl-specific exporter, endpoint, protocol, and header variables from the
final standard values, so overrides such as `.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", url)`
apply to Perl. Set an `OTEL_PERL_*` variable explicitly to override only the Perl SDK.

---

## Additional Information

For more info visit <https://aspire.dev/integrations/frameworks/perl/>.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using Aspire.Hosting;
using CommunityToolkit.Aspire.Testing;

namespace CommunityToolkit.Aspire.Hosting.Perl.Tests;

public class OtlpConfigurationTests
{
private const string GrpcEndpoint = "http://localhost:4317";
private const string HttpEndpoint = "http://localhost:4318";

[Theory]
[InlineData(null)]
[InlineData(GrpcEndpoint)]
public async Task AddPerlScript_WithNoHttpOtlpEndpoint_DoesNotConfigurePerlOtlpExport(string? grpcEndpoint)
{
var builder = CreateBuilder(grpcEndpoint, httpEndpoint: null);
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl");

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", environment.Keys);
Assert.DoesNotContain("OTEL_EXPORTER_OTLP_PROTOCOL", environment.Keys);
Assert.DoesNotContain(environment.Keys, key => key.StartsWith("OTEL_PERL_", StringComparison.Ordinal));
Assert.DoesNotContain("OTEL_TRACES_EXPORTER", environment.Keys);
Assert.DoesNotContain("OTEL_LOGS_EXPORTER", environment.Keys);
Assert.DoesNotContain("OTEL_METRICS_EXPORTER", environment.Keys);
AssertRequiredProtocol(perlApp.Resource);
}

[Theory]
[InlineData(null)]
[InlineData(GrpcEndpoint)]
public async Task AddPerlScript_WithHttpOtlpEndpoint_UsesMatchingHttpJsonConfiguration(string? grpcEndpoint)
{
var builder = CreateBuilder(grpcEndpoint, HttpEndpoint);
builder.Configuration["AppHost:OtlpApiKey"] = "test-key";
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl");

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.Equal(HttpEndpoint, environment["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal("http/json", environment["OTEL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_ENDPOINT"], environment["OTEL_PERL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_PROTOCOL"], environment["OTEL_PERL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_HEADERS"], environment["OTEL_PERL_EXPORTER_OTLP_HEADERS"]);
Assert.All(
new[] { "TRACES", "LOGS", "METRICS" },
signal =>
{
Assert.Equal("otlp", environment[$"OTEL_{signal}_EXPORTER"]);
Assert.Equal("otlp", environment[$"OTEL_PERL_{signal}_EXPORTER"]);
});
AssertRequiredProtocol(perlApp.Resource);
}

[Theory]
[InlineData("ENDPOINT", "http://collector.test:4318")]
[InlineData("PROTOCOL", "http/protobuf")]
[InlineData("HEADERS", "authorization=collector-test-key")]
public async Task AddPerlScript_WithStandardOtlpOverride_PerlConfigurationUsesOverride(string setting, string value)
{
var builder = CreateBuilder(GrpcEndpoint, HttpEndpoint);
builder.Configuration["AppHost:OtlpApiKey"] = "dashboard-test-key";
var standardName = $"OTEL_EXPORTER_OTLP_{setting}";
var perlName = $"OTEL_PERL_EXPORTER_OTLP_{setting}";
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl")
.WithEnvironment(standardName, value);

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.Equal(value, environment[standardName]);
Assert.Equal(value, environment[perlName]);
}

[Theory]
[InlineData("TRACES")]
[InlineData("LOGS")]
[InlineData("METRICS")]
public async Task AddPerlScript_WithStandardExporterOverride_PerlConfigurationUsesOverride(string signal)
{
var builder = CreateBuilder(GrpcEndpoint, HttpEndpoint);
var standardName = $"OTEL_{signal}_EXPORTER";
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl")
.WithEnvironment(standardName, "none");

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.Equal("none", environment[standardName]);
Assert.Equal("none", environment[$"OTEL_PERL_{signal}_EXPORTER"]);
}

[Theory]
[InlineData("http://collector.test:4318", "http/protobuf", "authorization=collector-test-key")]
[InlineData("http://alternate-collector.test:14318", "http/json", "authorization=alternate-test-key")]
public async Task AddPerlScript_WithStandardOtlpOverridesInCallback_PerlConfigurationUsesFinalValues(
string endpoint, string protocol, string headers)
{
var builder = CreateBuilder(GrpcEndpoint, HttpEndpoint);
builder.Configuration["AppHost:OtlpApiKey"] = "dashboard-test-key";
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl")
.WithEnvironment(context =>
{
context.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint;
context.EnvironmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = protocol;
context.EnvironmentVariables["OTEL_EXPORTER_OTLP_HEADERS"] = headers;
});

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.Equal(endpoint, environment["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal(protocol, environment["OTEL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal(headers, environment["OTEL_EXPORTER_OTLP_HEADERS"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_ENDPOINT"], environment["OTEL_PERL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_PROTOCOL"], environment["OTEL_PERL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal(environment["OTEL_EXPORTER_OTLP_HEADERS"], environment["OTEL_PERL_EXPORTER_OTLP_HEADERS"]);
}

[Fact]
public async Task AddPerlScript_WithExplicitPerlOtlpOverrides_PreservesPerlSpecificConfiguration()
{
var builder = CreateBuilder(GrpcEndpoint, HttpEndpoint);
builder.Configuration["AppHost:OtlpApiKey"] = "dashboard-test-key";
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl")
.WithEnvironment("OTEL_PERL_EXPORTER_OTLP_ENDPOINT", "http://collector.test:4318")
.WithEnvironment("OTEL_PERL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
.WithEnvironment("OTEL_PERL_EXPORTER_OTLP_HEADERS", "authorization=collector-test-key");

using var app = builder.Build();
var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services);

Assert.Equal("http://collector.test:4318", environment["OTEL_PERL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal("http/protobuf", environment["OTEL_PERL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal("authorization=collector-test-key", environment["OTEL_PERL_EXPORTER_OTLP_HEADERS"]);
Assert.Equal(HttpEndpoint, environment["OTEL_EXPORTER_OTLP_ENDPOINT"]);
Assert.Equal("http/json", environment["OTEL_EXPORTER_OTLP_PROTOCOL"]);
Assert.Equal("x-otlp-api-key=dashboard-test-key", environment["OTEL_EXPORTER_OTLP_HEADERS"]);
}

[Fact]
public async Task AddPerlScript_InPublishMode_LeavesOtlpEndpointToDeploymentTarget()
{
var builder = DistributedApplication.CreateBuilder();
var perlApp = builder.AddPerlScript("perl-app", "scripts", "app.pl");

var environment = await perlApp.Resource.GetEnvironmentVariablesAsync(DistributedApplicationOperation.Publish);

Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", environment.Keys);
Assert.DoesNotContain("OTEL_EXPORTER_OTLP_PROTOCOL", environment.Keys);
Assert.DoesNotContain("OTEL_PERL_EXPORTER_OTLP_ENDPOINT", environment.Keys);
Assert.DoesNotContain("OTEL_PERL_EXPORTER_OTLP_PROTOCOL", environment.Keys);
Assert.DoesNotContain("OTEL_EXPORTER_OTLP_HEADERS", environment.Keys);
Assert.DoesNotContain("OTEL_PERL_EXPORTER_OTLP_HEADERS", environment.Keys);
Assert.All(
new[] { "TRACES", "LOGS", "METRICS" },
signal =>
{
Assert.Equal("otlp", environment[$"OTEL_{signal}_EXPORTER"]);
Assert.Equal("otlp", environment[$"OTEL_PERL_{signal}_EXPORTER"]);
});
AssertRequiredProtocol(perlApp.Resource);
}

private static IDistributedApplicationBuilder CreateBuilder(string? grpcEndpoint, string? httpEndpoint)
{
var builder = DistributedApplication.CreateBuilder();
builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = grpcEndpoint;
builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = httpEndpoint;
builder.Configuration["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = null;
builder.Configuration["DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = null;
return builder;
}

private static void AssertRequiredProtocol(PerlAppResource resource)
{
var annotation = Assert.Single(resource.Annotations.OfType<OtlpExporterAnnotation>());
Assert.Equal(OtlpProtocol.HttpJson, annotation.RequiredProtocol);
}
}
Loading