From e01c025da2e13aaecc621d2843028a1c8db1a4a6 Mon Sep 17 00:00:00 2001 From: Matthew Austew Date: Tue, 27 Oct 2026 00:24:29 -0500 Subject: [PATCH] This update resolves some disarray with the OTLP endpoint setting mismatching between aspire and called perl resources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aspire.config.json | 1 + .../Properties/launchSettings.json | 2 + .../PerlAppResourceBuilderExtensions.cs | 45 +++-- .../PerlOtelEnvironmentVariableMirror.cs | 44 +++++ .../README.md | 17 ++ .../OtlpConfigurationTests.cs | 183 ++++++++++++++++++ 6 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.Perl/PerlOtelEnvironmentVariableMirror.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/OtlpConfigurationTests.cs diff --git a/examples/perl/CpanmApiIntegration.AppHost.TypeScript/aspire.config.json b/examples/perl/CpanmApiIntegration.AppHost.TypeScript/aspire.config.json index 55842c168..d27650b8f 100644 --- a/examples/perl/CpanmApiIntegration.AppHost.TypeScript/aspire.config.json +++ b/examples/perl/CpanmApiIntegration.AppHost.TypeScript/aspire.config.json @@ -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" } } diff --git a/examples/perl/CpanmApiIntegration.AppHost/Properties/launchSettings.json b/examples/perl/CpanmApiIntegration.AppHost/Properties/launchSettings.json index 270f6d5fd..9cfc434ee 100644 --- a/examples/perl/CpanmApiIntegration.AppHost/Properties/launchSettings.json +++ b/examples/perl/CpanmApiIntegration.AppHost/Properties/launchSettings.json @@ -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" } }, @@ -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" } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs index bb42e040d..254cdbabc 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlAppResourceBuilderExtensions.cs @@ -214,26 +214,41 @@ private static IResourceBuilder 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) @@ -539,6 +554,14 @@ private static Task ConfigurePerlCertificateTrust(CertificateTrustConfigurationC return Task.CompletedTask; } + /// + /// Sets a Perl-specific OpenTelemetry variable to resolve from the final standard variable value. + /// + private static void MirrorPerlOtelVariable(EnvironmentCallbackContext context, string sourceName, string targetName) + { + context.EnvironmentVariables[targetName] = new PerlOtelEnvironmentVariableMirror(context.EnvironmentVariables, sourceName); + } + /// /// Resolves the local::lib path from the resource's , /// returning null when no local::lib is configured. diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/PerlOtelEnvironmentVariableMirror.cs b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlOtelEnvironmentVariableMirror.cs new file mode 100644 index 000000000..e6625d644 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/PerlOtelEnvironmentVariableMirror.cs @@ -0,0 +1,44 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Perl; + +/// +/// Resolves a Perl-specific OpenTelemetry variable from the final value of its standard variable. +/// +/// The environment variables shared by the resource's environment callbacks. +/// The standard OpenTelemetry variable to read. +internal sealed class PerlOtelEnvironmentVariableMirror( + IReadOnlyDictionary environmentVariables, + string sourceName) : IValueProvider, IValueWithReferences +{ + /// + public IEnumerable References => + environmentVariables.TryGetValue(sourceName, out var value) && !ReferenceEquals(value, this) + ? [value] + : []; + + /// + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) => + GetValueAsync(new ValueProviderContext(), cancellationToken); + + /// + public async ValueTask 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 { Resource: IValueProvider provider } => + await provider.GetValueAsync(context, cancellationToken).ConfigureAwait(false), + _ => value.ToString() + }; + } + + /// + public override string ToString() => sourceName; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Perl/README.md b/src/CommunityToolkit.Aspire.Hosting.Perl/README.md index 7bf1a5166..70daefffb 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Perl/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Perl/README.md @@ -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 . diff --git a/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/OtlpConfigurationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/OtlpConfigurationTests.cs new file mode 100644 index 000000000..2412f8c4b --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Perl.Tests/OtlpConfigurationTests.cs @@ -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()); + Assert.Equal(OtlpProtocol.HttpJson, annotation.RequiredProtocol); + } +}