diff --git a/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs b/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs index 99f62e6ab47..3d6f519743a 100644 --- a/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs +++ b/playground/BlazorHosted/BlazorHosted.ClientServiceDefaults/Extensions.cs @@ -35,7 +35,7 @@ public static WebAssemblyHostBuilder AddBlazorClientServiceDefaults(this WebAsse private static WebAssemblyHostBuilder ConfigureBlazorClientOpenTelemetry(this WebAssemblyHostBuilder builder) { // Without an OTLP path base, there's nowhere to export telemetry in WASM. - var otlpPathBase = builder.Configuration["ASPIRE_OTLP_PATH_BASE"]; + var otlpPathBase = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (string.IsNullOrEmpty(otlpPathBase)) { return builder; diff --git a/playground/BlazorHosted/BlazorHosted.Server/Program.cs b/playground/BlazorHosted/BlazorHosted.Server/Program.cs index f3aa7efe449..6acf13d0337 100644 --- a/playground/BlazorHosted/BlazorHosted.Server/Program.cs +++ b/playground/BlazorHosted/BlazorHosted.Server/Program.cs @@ -4,6 +4,30 @@ builder.AddServiceDefaults(); +// Filter out OTLP proxy traffic from tracing to prevent a feedback loop: +// YARP forwards /_otlp/* requests to the dashboard, and without filtering, +// those forwarding requests would themselves be traced and exported — creating +// recursive telemetry entries in the dashboard. +builder.Services.PostConfigure(options => +{ + var previous = options.Filter; + options.Filter = context => + { + var path = context.Request.Path.Value; + return (previous is null || previous(context)) + && (path is null || !path.Contains("/_otlp/", StringComparison.Ordinal)); + }; +}); + +builder.Services.PostConfigure(options => +{ + var previous = options.FilterHttpRequestMessage; + options.FilterHttpRequestMessage = request => + (previous is null || previous(request)) + && (request.RequestUri is null + || !request.RequestUri.AbsolutePath.StartsWith("/v1/", StringComparison.Ordinal)); +}); + builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); diff --git a/playground/BlazorHosted/README.md b/playground/BlazorHosted/README.md index aaef5f50059..7ea34ea91a9 100644 --- a/playground/BlazorHosted/README.md +++ b/playground/BlazorHosted/README.md @@ -1,6 +1,6 @@ # BlazorHosted -This sample demonstrates how to integrate a **hosted Blazor WebAssembly** application (Blazor Web App with Interactive WebAssembly render mode) with .NET Aspire, enabling full observability (logs, traces) and service discovery for both the server and the WASM client. +This sample demonstrates how to integrate a **hosted Blazor WebAssembly** application (Blazor Web App with Interactive WebAssembly render mode) with Aspire, enabling full observability (logs, traces) and service discovery for both the server and the WASM client. ## Overview diff --git a/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs b/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs index 99f62e6ab47..3d6f519743a 100644 --- a/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs +++ b/playground/BlazorStandalone/BlazorStandalone.ClientServiceDefaults/Extensions.cs @@ -35,7 +35,7 @@ public static WebAssemblyHostBuilder AddBlazorClientServiceDefaults(this WebAsse private static WebAssemblyHostBuilder ConfigureBlazorClientOpenTelemetry(this WebAssemblyHostBuilder builder) { // Without an OTLP path base, there's nowhere to export telemetry in WASM. - var otlpPathBase = builder.Configuration["ASPIRE_OTLP_PATH_BASE"]; + var otlpPathBase = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; if (string.IsNullOrEmpty(otlpPathBase)) { return builder; diff --git a/playground/BlazorStandalone/README.md b/playground/BlazorStandalone/README.md index 2a69d4235fe..b870cdad308 100644 --- a/playground/BlazorStandalone/README.md +++ b/playground/BlazorStandalone/README.md @@ -1,6 +1,6 @@ # BlazorStandalone -This sample demonstrates how to integrate a **standalone Blazor WebAssembly** application with .NET Aspire, enabling full observability (logs, traces) and service discovery without requiring a hosted Blazor Server backend. +This sample demonstrates how to integrate a **standalone Blazor WebAssembly** application with Aspire, enabling full observability (logs, traces) and service discovery without requiring a hosted Blazor Server backend. ## Overview diff --git a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj index 4a94f17a9ce..757c106895d 100644 --- a/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj +++ b/src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj @@ -11,8 +11,20 @@ 0 false + + 10.0 + + + <_Parameter1>BlazorGatewayDotNetImageTag + <_Parameter2>$(BlazorGatewayDotNetImageTag) + + + diff --git a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs index ef0ba79a06a..3c915771715 100644 --- a/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs @@ -21,10 +21,6 @@ namespace Aspire.Hosting; [Experimental("ASPIREBLAZOR001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static class BlazorGatewayExtensions { - // Derive the .NET image tag from the runtime version of the app host process. - // The Gateway is a file-based app compiled with the same SDK, so the major.minor - // version of the running host matches the required SDK/ASP.NET base images. - // Pre-release runtimes (preview/RC) use suffixed tags like "10.0-preview" or "11.0-rc". private static readonly string s_dotNetImageTag = GetDotNetImageTag(); private const string DotNetSdkImageRepo = "mcr.microsoft.com/dotnet/sdk"; private const string DotNetAspNetImageRepo = "mcr.microsoft.com/dotnet/aspnet"; @@ -37,7 +33,7 @@ public static class BlazorGatewayExtensions [AspireExportIgnore(Reason = "Blazor gateway APIs are not yet stable for ATS export.")] public static IResourceBuilder AddBlazorGateway( this IDistributedApplicationBuilder builder, - string name) + [ResourceName] string name) { var gatewayPath = GetScriptPath("Gateway.cs"); var gateway = builder.AddCSharpApp(name, gatewayPath) @@ -84,7 +80,7 @@ public static IResourceBuilder AddBlazorGateway( [AspireExportIgnore(Reason = "Open generic type parameter TProject is not ATS-compatible.")] public static IResourceBuilder AddBlazorWasmProject( this IDistributedApplicationBuilder builder, - string name) + [ResourceName] string name) where TProject : IProjectMetadata, new() { var metadata = new TProject(); @@ -109,7 +105,7 @@ public static IResourceBuilder AddBlazorWasmProject AddBlazorWasmApp( this IDistributedApplicationBuilder builder, - string name, + [ResourceName] string name, string projectPath) { var resolvedPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, projectPath)); @@ -201,8 +197,7 @@ internal static IResourceBuilder WithBlazorApp( var annotation = GetOrAddGatewayAppsAnnotation(gateway.Resource); var gatewayOutputRoot = Path.Combine( - gateway.ApplicationBuilder.AppHostDirectory, - "obj", "Aspire.Hosting.Blazor", "gateways", gateway.Resource.Name); + GetBlazorStorePath(gateway.ApplicationBuilder), "gateways", gateway.Resource.Name); if (!annotation.IsInitialized) { @@ -217,6 +212,20 @@ internal static IResourceBuilder WithBlazorApp( var gatewayEndpoint = httpsGatewayEndpoint ?? httpGatewayEndpoint ?? throw new InvalidOperationException($"The gateway '{gateway.Resource.Name}' must define an HTTP or HTTPS endpoint."); + // Resolve the HTTP OTLP endpoint for WASM client proxying. + // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. + // First try to resolve from the dashboard resource model (handles randomized ports + // and isolated mode). Fall back to configuration for cases where the dashboard + // resource isn't in the model (e.g. external dashboard). + var httpOtlpEndpointUrl = ResolveHttpOtlpEndpointUrl(context, gateway.ApplicationBuilder.Configuration); + + if (httpOtlpEndpointUrl is null && registeredApps.Any(a => a.ProxyBlazorTelemetry)) + { + context.Logger.LogWarning( + "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + + "WASM client telemetry will not be forwarded."); + } + if (context.ExecutionContext.IsPublishMode) { ConfigurePublishEnvironment(context, registeredApps, gatewayEndpoint, httpGatewayEndpoint); @@ -247,15 +256,7 @@ internal static IResourceBuilder WithBlazorApp( await EndpointsManifestTransformer.MergeRuntimeManifestsAsync(manifests, mergedRuntimePath, context.Logger, context.CancellationToken).ConfigureAwait(false); context.EnvironmentVariables["staticWebAssets"] = mergedRuntimePath; - // Resolve the HTTP OTLP endpoint for WASM client proxying. - // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. - // First try to resolve from the dashboard resource model (handles randomized ports - // and isolated mode). Fall back to configuration for cases where the dashboard - // resource isn't in the model (e.g. external dashboard). - var httpOtlpEndpointUrl = ResolveHttpOtlpEndpointUrl(context, gateway.ApplicationBuilder.Configuration); - var resourceLoggerService = context.ExecutionContext.ServiceProvider.GetRequiredService(); - - GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl, resourceLoggerService); + GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl); }); } @@ -413,14 +414,14 @@ private static void CreatePublishCompanion( var relativeProjectPath = Path.GetRelativePath( project.SolutionRoot, wasmApp.Resource.ProjectPath).Replace('\\', '/'); - // Copy the PrefixEndpoints.cs script into a project-local build folder so it's - // available inside the Docker build context without clobbering the solution root. + // Copy PrefixEndpoints.cs into .aspire/scripts/ within the solution root so it's + // included in the Docker build context. var scriptSource = GetScriptPath("PrefixEndpoints.cs"); - var scriptRelativePath = Path.Combine(project.RelativeProjectPath, "obj", "Aspire.Hosting.Blazor", "PrefixEndpoints.cs") - .Replace('\\', '/'); - var scriptDest = Path.Combine(project.SolutionRoot, scriptRelativePath.Replace('/', Path.DirectorySeparatorChar)); + var scriptDest = Path.Combine(project.SolutionRoot, ".aspire", "scripts", "PrefixEndpoints.cs"); Directory.CreateDirectory(Path.GetDirectoryName(scriptDest)!); File.Copy(scriptSource, scriptDest, overwrite: true); + var scriptRelativePath = Path.GetRelativePath(project.SolutionRoot, scriptDest) + .Replace('\\', '/'); var companion = gateway.ApplicationBuilder.AddResource( new BlazorWasmPublishResource(publishResourceName)) @@ -466,6 +467,19 @@ private static string GetScriptPath(string scriptName) return scriptPath; } + private const string AspireStorePathKey = "Aspire:Store:Path"; + + /// + /// Gets the Blazor-specific store path under the Aspire store directory. + /// + private static string GetBlazorStorePath(IDistributedApplicationBuilder builder) + { + var storePath = builder.Configuration[AspireStorePathKey] + ?? builder.AppHostDirectory; + + return Path.Combine(storePath, ".aspire", "blazor"); + } + private static List GetServiceDiscoveryReferences(IResource resource) { // EndpointReferenceAnnotation is added by WithReference and tracks which endpoint @@ -637,30 +651,58 @@ private readonly struct ProjectInfo(string solutionRoot, string relativeProjectP } /// - /// Resolves the Docker image tag for the .NET SDK/ASP.NET base images. - /// Returns "Major.Minor" for stable releases (e.g. "10.0", "11.0"), - /// and appends "-preview" or "-rc" for pre-release runtimes to match the - /// MCR tag naming convention (e.g. "10.0-preview", "11.0-rc"). + /// Resolves the Docker image tag for .NET base images. Uses the maximum of the build-time + /// stamped version and the actual runtime version, with pre-release suffix when applicable. /// private static string GetDotNetImageTag() { - var tag = $"{Environment.Version.Major}.{Environment.Version.Minor}"; + var runtimeMajor = Environment.Version.Major; + var runtimeMinor = Environment.Version.Minor; - // The runtime's informational version contains the full pre-release label, - // e.g. "10.0.0-preview.7.25352.1+..." or "11.0.0-rc.1.25400.3+...". - // Stable/servicing builds use "10.0.6-servicing..." which we ignore. - var informationalVersion = (System.Reflection.AssemblyInformationalVersionAttribute?) - Attribute.GetCustomAttribute(typeof(object).Assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + var stampedMajor = runtimeMajor; + var stampedMinor = runtimeMinor; - if (informationalVersion is not null) + var stampedValue = typeof(BlazorGatewayExtensions).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), inherit: false) + .OfType() + .FirstOrDefault(a => a.Key == "BlazorGatewayDotNetImageTag") + ?.Value; + + if (!string.IsNullOrEmpty(stampedValue)) { - if (informationalVersion.InformationalVersion.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + var parts = stampedValue.Split('.'); + if (parts.Length >= 2 + && int.TryParse(parts[0], out var sMajor) + && int.TryParse(parts[1], out var sMinor)) { - tag += "-preview"; + stampedMajor = sMajor; + stampedMinor = sMinor; } - else if (informationalVersion.InformationalVersion.Contains("-rc", StringComparison.OrdinalIgnoreCase)) + } + + var major = Math.Max(runtimeMajor, stampedMajor); + var minor = (major == runtimeMajor && major == stampedMajor) + ? Math.Max(runtimeMinor, stampedMinor) + : (major == runtimeMajor ? runtimeMinor : stampedMinor); + + var tag = $"{major}.{minor}"; + + // Append pre-release suffix when the runtime version won and is pre-release. + if (major == runtimeMajor && minor == runtimeMinor) + { + var informationalVersion = (System.Reflection.AssemblyInformationalVersionAttribute?) + Attribute.GetCustomAttribute(typeof(object).Assembly, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + + if (informationalVersion is not null) { - tag += "-rc"; + if (informationalVersion.InformationalVersion.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + { + tag += "-preview"; + } + else if (informationalVersion.InformationalVersion.Contains("-rc", StringComparison.OrdinalIgnoreCase)) + { + tag += "-rc"; + } } } diff --git a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs index ebdf29f9389..c0974c39ee5 100644 --- a/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs +++ b/src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; #pragma warning disable ASPIREATS001 // AspireExportIgnore is experimental @@ -91,6 +92,13 @@ private static void EnsureEnvironmentCallback( // WASM clients use HTTP/protobuf (not gRPC), so we need the HTTP endpoint. var httpOtlpEndpointUrl = BlazorGatewayExtensions.ResolveHttpOtlpEndpointUrl(context, host.ApplicationBuilder.Configuration); + if (httpOtlpEndpointUrl is null && annotation.ProxyBlazorTelemetry) + { + context.Logger.LogWarning( + "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + + "WASM client telemetry will not be forwarded."); + } + GatewayConfigurationBuilder.EmitHostedProxyConfiguration( context.EnvironmentVariables, hostEndpoint, @@ -99,7 +107,6 @@ private static void EnsureEnvironmentCallback( annotation.Services, annotation.ProxyBlazorTelemetry, httpOtlpEndpointUrl, - context.Logger, annotation.OtlpPrefix); }); } diff --git a/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs b/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs index ead8b400353..ed098c9a572 100644 --- a/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs +++ b/src/Aspire.Hosting.Blazor/GatewayConfigurationBuilder.cs @@ -1,10 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting; @@ -23,8 +23,7 @@ public static void EmitProxyConfiguration( List apps, EndpointReference gatewayEndpoint, EndpointReference? httpGatewayEndpoint = null, - object? httpOtlpEndpoint = null, - ResourceLoggerService? resourceLoggerService = null) + object? httpOtlpEndpoint = null) { var addedClusters = new HashSet(); var httpClientEndpoint = httpGatewayEndpoint ?? (gatewayEndpoint.IsHttp ? gatewayEndpoint : null); @@ -35,8 +34,6 @@ public static void EmitProxyConfiguration( var prefix = reg.PathPrefix; var envPrefix = $"ClientApps__{reg.Resource.Name}"; - // Per-app client config: use an IValueProvider that resolves the gateway URL - // at startup and builds the final JSON response. // Flatten services: one HostedClientService per named endpoint so each gets // its own YARP cluster destination. var servicesList = new List(); @@ -56,8 +53,7 @@ public static void EmitProxyConfiguration( } var services = servicesList.ToArray(); - env[$"{envPrefix}__ConfigResponse"] = new ClientConfigValueProvider( - gatewayEndpoint, + env[$"{envPrefix}__ConfigResponse"] = BuildConfigExpression( httpClientEndpoint, httpsClientEndpoint, prefix, @@ -65,7 +61,6 @@ public static void EmitProxyConfiguration( services, reg.ProxyBlazorTelemetry, httpOtlpEndpoint, - resourceLoggerService?.GetLogger(reg.Resource) ?? NullLogger.Instance, reg.OtlpPrefix); EmitYarpRoutes(env, prefix, reg.Resource.Name, services, reg.ProxyBlazorTelemetry, addedClusters, @@ -90,14 +85,12 @@ public static void EmitHostedProxyConfiguration( IReadOnlyList services, bool proxyBlazorTelemetry, object? httpOtlpEndpoint, - ILogger? logger = null, string otlpPrefix = DefaultOtlpPrefix) { var httpClientEndpoint = httpHostEndpoint ?? (hostEndpoint.IsHttp ? hostEndpoint : null); var httpsClientEndpoint = hostEndpoint.IsHttps ? hostEndpoint : null; - env["Client__ConfigResponse"] = new ClientConfigValueProvider( - hostEndpoint, + env["Client__ConfigResponse"] = BuildConfigExpression( httpClientEndpoint, httpsClientEndpoint, prefix: null, @@ -105,7 +98,6 @@ public static void EmitHostedProxyConfiguration( services, proxyBlazorTelemetry, httpOtlpEndpoint, - logger ?? NullLogger.Instance, otlpPrefix); env["Client__ConfigEndpointPath"] = "/_blazor/_configuration"; @@ -128,6 +120,12 @@ public static void EmitHostedProxyConfiguration( /// internal const string DefaultOtlpPrefix = "_otlp"; + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + private static void EmitYarpRoutes( IDictionary env, string? prefix, @@ -197,15 +195,19 @@ private static void EmitOtlpCluster(IDictionary env, object? htt } } + // All string values placed into the JSON are constructed by us: a brace-free token + // (__ORIGIN__) concatenated with URL path segments (e.g. "/app/_api/weatherapi"). + // Because none of these values contain { or }, the only literal braces in the + // serialized output are structural JSON. This invariant makes the Replace("{","{{") + // step below correct — if a future change introduces braces in values, the + // string.Format template would break and tests would catch it immediately. + private const string OriginToken = "__ORIGIN__"; + /// - /// An IValueProvider that resolves an endpoint URL and builds the - /// Blazor WASM configuration JSON response. At run time, the URL is - /// resolved from the EndpointReference. At publish time, ValueExpression emits - /// the JSON with manifest expression placeholders for the deployer to resolve. - /// Used by both the standalone gateway and hosted Blazor models. + /// Builds the ConfigResponse JSON as a . In dev mode the + /// gateway origin resolves to the actual URL; in publish mode publishers emit a placeholder. /// - internal sealed class ClientConfigValueProvider( - EndpointReference primaryEndpoint, + internal static ReferenceExpression BuildConfigExpression( EndpointReference? httpEndpoint, EndpointReference? httpsEndpoint, string? prefix, @@ -213,118 +215,74 @@ internal sealed class ClientConfigValueProvider( IReadOnlyList services, bool proxyBlazorTelemetry, object? httpOtlpEndpoint, - ILogger logger, - string otlpPrefix = DefaultOtlpPrefix) : IValueProvider, IManifestExpressionProvider + string otlpPrefix = DefaultOtlpPrefix) { - string IManifestExpressionProvider.ValueExpression => - BuildJson( - ((IManifestExpressionProvider)primaryEndpoint).ValueExpression, - ResolveEndpointExpression(httpEndpoint), - ResolveEndpointExpression(httpsEndpoint)); + var pathBase = prefix != null ? $"/{prefix}" : ""; - async ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) - { - LogOtlpWarningIfNeeded(); - var primaryUrl = await primaryEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); - var httpUrl = await ResolveEndpointAsync(httpEndpoint, cancellationToken).ConfigureAwait(false); - var httpsUrl = await ResolveEndpointAsync(httpsEndpoint, cancellationToken).ConfigureAwait(false); - return BuildJson(primaryUrl, httpUrl, httpsUrl); - } + var environment = new JsonObject(); - async ValueTask IValueProvider.GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) - { - LogOtlpWarningIfNeeded(); - var primaryUrl = await primaryEndpoint.GetValueAsync(context, cancellationToken).ConfigureAwait(false); - var httpUrl = await ResolveEndpointAsync(httpEndpoint, context, cancellationToken).ConfigureAwait(false); - var httpsUrl = await ResolveEndpointAsync(httpsEndpoint, context, cancellationToken).ConfigureAwait(false); - return BuildJson(primaryUrl, httpUrl, httpsUrl); - } - - private void LogOtlpWarningIfNeeded() + foreach (var svc in services) { - if (proxyBlazorTelemetry && httpOtlpEndpoint is null) + if (httpsEndpoint is not null) { - logger.LogWarning( - "OTLP telemetry proxying was requested but no dashboard HTTP endpoint could be resolved. " + - "WASM client telemetry will not be forwarded."); + environment[$"services__{svc.ServiceName}__https__0"] = $"{OriginToken}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; } - } - private static async ValueTask ResolveEndpointAsync(EndpointReference? endpoint, CancellationToken cancellationToken) - { - if (endpoint is null) + if (httpEndpoint is not null) { - return null; + environment[$"services__{svc.ServiceName}__http__0"] = $"{OriginToken}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; } - - return await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); } - private static async ValueTask ResolveEndpointAsync(EndpointReference? endpoint, ValueProviderContext context, CancellationToken cancellationToken) + if (proxyBlazorTelemetry && httpOtlpEndpoint is not null) { - if (endpoint is null) - { - return null; - } - - return await endpoint.GetValueAsync(context, cancellationToken).ConfigureAwait(false); + environment["OTEL_SERVICE_NAME"] = resourceName; + + // Send only the OTLP path so the WASM client resolves it against its own + // page origin (HostEnvironment.BaseAddress). This avoids cross-origin issues + // when the user navigates via HTTP but the gateway also exposes HTTPS. + environment["OTEL_EXPORTER_OTLP_ENDPOINT"] = $"{pathBase}/{otlpPrefix}"; + environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"; + + // NOTE: OTEL_EXPORTER_OTLP_HEADERS is intentionally NOT sent to the WASM client. + // The headers contain the dashboard OTLP API key, and this config is delivered + // to browser-visible JSON. The YARP proxy injects the headers server-side when + // forwarding telemetry to the dashboard. } - private static string? ResolveEndpointExpression(EndpointReference? endpoint) + var config = new JsonObject { - return endpoint is IManifestExpressionProvider manifestExpressionProvider - ? manifestExpressionProvider.ValueExpression - : null; - } + ["webAssembly"] = new JsonObject + { + ["environment"] = environment + } + }; - private string BuildJson(string? primaryBaseUrl, string? httpBaseUrl, string? httpsBaseUrl) - { - var pathBase = prefix != null ? $"/{prefix}" : ""; - var environment = new Dictionary(); - var normalizedPrimaryBaseUrl = NormalizeUrl(primaryBaseUrl); - var normalizedHttpBaseUrl = NormalizeUrl(httpBaseUrl ?? (primaryEndpoint.IsHttp ? normalizedPrimaryBaseUrl : null)); - var normalizedHttpsBaseUrl = NormalizeUrl(httpsBaseUrl ?? (primaryEndpoint.IsHttps ? normalizedPrimaryBaseUrl : null)); + var json = config.ToJsonString(s_jsonOptions); - foreach (var svc in services) - { - if (normalizedHttpsBaseUrl is not null) - { - environment[$"services__{svc.ServiceName}__https__0"] = $"{normalizedHttpsBaseUrl}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; - } + // The only literal { } in the output are structural JSON braces (we control + // all string values and they contain no braces). Escape them for string.Format, + // then swap the origin token for {0}. + var format = json + .Replace("{", "{{") + .Replace("}", "}}") + .Replace(OriginToken, "{0}"); - if (normalizedHttpBaseUrl is not null) - { - environment[$"services__{svc.ServiceName}__http__0"] = $"{normalizedHttpBaseUrl}{pathBase}/{svc.ApiPrefix}/{svc.ServiceName}"; - } - } + var originEndpoint = httpsEndpoint ?? httpEndpoint + ?? throw new InvalidOperationException("At least one gateway endpoint (HTTP or HTTPS) must be provided."); - if (proxyBlazorTelemetry && httpOtlpEndpoint is not null) + var originRef = new GatewayOriginReference(originEndpoint); + var builder = new ReferenceExpressionBuilder(); + var segments = format.Split("{0}"); + for (var i = 0; i < segments.Length; i++) + { + if (i > 0) { - environment["OTEL_SERVICE_NAME"] = resourceName; - - // Send only the OTLP path so the WASM client resolves it against its own - // page origin (HostEnvironment.BaseAddress). This avoids cross-origin issues - // when the user navigates via HTTP but the gateway also exposes HTTPS. - environment["ASPIRE_OTLP_PATH_BASE"] = $"{pathBase}/{otlpPrefix}"; - environment["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"; - - // NOTE: OTEL_EXPORTER_OTLP_HEADERS is intentionally NOT sent to the WASM client. - // The headers contain the dashboard OTLP API key, and this config is delivered - // to browser-visible JSON. The YARP proxy injects the headers server-side when - // forwarding telemetry to the dashboard. + builder.AppendFormatted(originRef); } - - return JsonSerializer.Serialize( - new ClientConfiguration - { - WebAssembly = new WebAssemblyConfiguration { Environment = environment } - }, - ManifestJsonContext.Relaxed.ClientConfiguration); + builder.AppendLiteral(segments[i]); } - private static string? NormalizeUrl(string? url) - { - return string.IsNullOrEmpty(url) ? null : url.TrimEnd('/'); - } + return builder.Build(); } } diff --git a/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs b/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs new file mode 100644 index 00000000000..eff04d813ca --- /dev/null +++ b/src/Aspire.Hosting.Blazor/GatewayOriginReference.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Wraps a gateway so publishers can emit a deployer-configurable +/// placeholder (e.g., ${GATEWAY_BINDINGS_HTTPS_URL}) while dev mode resolves the actual URL. +/// +internal sealed class GatewayOriginReference(EndpointReference endpoint) : IValueProvider, IManifestExpressionProvider +{ + public string ValueExpression => $"{{{endpoint.Resource.Name}.bindings.{endpoint.EndpointName}.url}}"; + + public async ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + var url = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + return url?.TrimEnd('/'); + } +} diff --git a/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs b/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs index ae961a4e155..03f5b583b8b 100644 --- a/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs +++ b/src/Aspire.Hosting.Blazor/Manifests/EndpointsManifestTransformer.cs @@ -21,7 +21,8 @@ public static async Task PrefixEndpointsAssetFileAsync(string manifestPa { var manifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false), - ManifestJsonContext.Default.EndpointsManifest)!; + ManifestJsonContext.Default.EndpointsManifest) + ?? throw new InvalidOperationException($"Failed to deserialize endpoints manifest from '{manifestPath}'."); var fallbackEndpoints = new List(); @@ -33,7 +34,7 @@ await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false), // We skip compressed variants (those with Content-Encoding selectors) because the // ContentEncodingNegotiationMatcherPolicy would otherwise prefer the catch-all over // literal routes (like _blazor/_configuration) that lack encoding metadata. - if (ep.Route == "index.html") + if (string.Equals(ep.Route, "index.html", StringComparison.OrdinalIgnoreCase)) { var hasContentEncoding = ep.Selectors?.Any(s => s.Name == "Content-Encoding") == true; diff --git a/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in b/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in index 0c2b8958e70..9553441aa6b 100644 --- a/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in +++ b/src/Aspire.Hosting.Blazor/Scripts/Gateway.cs.in @@ -79,7 +79,7 @@ foreach (var appConfig in appConfigs.Values) { if (!string.IsNullOrEmpty(appConfig.ConfigEndpointPath) && !string.IsNullOrEmpty(appConfig.ConfigResponse)) { - app.MapGet(appConfig.ConfigEndpointPath, () => Results.Content(appConfig.ConfigResponse, "application/json")) + app.MapGet(appConfig.ConfigEndpointPath, () => Results.Content(appConfig.ConfigResponse!, "application/json")) .WithMetadata(new ContentEncodingMetadata("identity", 1.0)); } @@ -150,9 +150,12 @@ static class ServiceDefaultsExtensions tracing.AddSource(builder.Environment.ApplicationName) .AddAspNetCoreInstrumentation(options => options.Filter = context => - !context.Request.Path.StartsWithSegments("/health") - && !context.Request.Path.StartsWithSegments("/alive") - && !IsStaticAssetOrOtlpRequest(context.Request.Path) + { + var path = context.Request.Path.Value; + return !context.Request.Path.StartsWithSegments("/health") + && !context.Request.Path.StartsWithSegments("/alive") + && (path is null || !path.Contains("/_otlp/", StringComparison.Ordinal)); + } ) .AddHttpClientInstrumentation(options => // Filter out the gateway's own OTLP export calls to the dashboard @@ -172,15 +175,6 @@ static class ServiceDefaultsExtensions return builder; } - private static bool IsStaticAssetOrOtlpRequest(PathString path) - { - var pathValue = path.Value; - return pathValue is not null - && (pathValue.Contains("/_framework/", StringComparison.Ordinal) - || pathValue.Contains("/_content/", StringComparison.Ordinal) - || pathValue.Contains("/_otlp/", StringComparison.Ordinal)); - } - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Services.AddHealthChecks() diff --git a/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs b/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs index 1498f2114c0..674503f1d31 100644 --- a/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs +++ b/tests/Aspire.Hosting.Blazor.Tests/BlazorHostedExtensionsTests.cs @@ -3,6 +3,8 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Hosting.Blazor.Tests; @@ -85,7 +87,7 @@ public async Task ProxyTelemetry_EmitsOtelServiceNameInConfig() var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); Assert.Contains("OTEL_SERVICE_NAME", configJson); Assert.Contains("blazorapp", configJson); - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("/_otlp", configJson); } @@ -114,7 +116,7 @@ public async Task ProxyService_And_ProxyTelemetry_Combined() // Config response includes both service URLs and OTLP var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); Assert.Contains("services__weatherapi__https__0", configJson); - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("OTEL_SERVICE_NAME", configJson); } @@ -182,7 +184,7 @@ public async Task ProxyService_WithoutProxyTelemetry_NoOtlpInConfig() Assert.False(env.ContainsKey("ReverseProxy__Routes__route-otlp__ClusterId")); var configJson = ResolveManifestExpression(env["Client__ConfigResponse"]); - Assert.DoesNotContain("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); } [Fact] @@ -282,16 +284,105 @@ public void ProxyService_MultipleServices_AllGetEndpointReferences() Assert.Contains("catalogapi", referencedNames); } + [Fact] + public async Task ProxyTelemetry_LogsWarning_WhenOtlpEndpointNotResolvable() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + // Intentionally NOT setting ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL + + builder.AddProject("blazorapp") + .WithHttpsEndpoint() + .ProxyBlazorTelemetry(); + + var blazorApp = builder.Resources.Single(r => r.Name == "blazorapp"); + var (_, sink) = await GetEnvironmentVariablesWithLogs(blazorApp, builder); + + Assert.Contains(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true && + msg.Message?.Contains("WASM client telemetry will not be forwarded") == true); + } + + [Fact] + public async Task ProxyTelemetry_DoesNotLogWarning_WhenOtlpEndpointIsConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + builder.AddProject("blazorapp") + .WithHttpsEndpoint() + .ProxyBlazorTelemetry(); + + var blazorApp = builder.Resources.Single(r => r.Name == "blazorapp"); + var (_, sink) = await GetEnvironmentVariablesWithLogs(blazorApp, builder); + + Assert.DoesNotContain(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true); + } + + [Fact] + public async Task WithBlazorClientApp_LogsWarning_WhenOtlpEndpointNotResolvable() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var gateway = builder.AddProject("gateway") + .WithHttpEndpoint() + .WithHttpsEndpoint(); + + var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj"); + gateway.WithBlazorClientApp(wasmApp, proxyTelemetry: true); + + var (_, sink) = await GetEnvironmentVariablesWithLogs(gateway.Resource, builder); + + Assert.Contains(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true && + msg.Message?.Contains("WASM client telemetry will not be forwarded") == true); + } + + [Fact] + public async Task WithBlazorClientApp_DoesNotLogWarning_WhenOtlpEndpointIsConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost:4318"; + + var gateway = builder.AddProject("gateway") + .WithHttpEndpoint() + .WithHttpsEndpoint(); + + var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj"); + gateway.WithBlazorClientApp(wasmApp, proxyTelemetry: true); + + var (_, sink) = await GetEnvironmentVariablesWithLogs(gateway.Resource, builder); + + Assert.DoesNotContain(sink.Writes, msg => + msg.LogLevel == LogLevel.Warning && + msg.Message?.Contains("OTLP telemetry proxying was requested") == true); + } + private static async Task> GetEnvironmentVariables( IResource resource, IDistributedApplicationBuilder builder) + { + var (env, _) = await GetEnvironmentVariablesWithLogs(resource, builder); + return env; + } + + private static async Task<(Dictionary Env, TestSink Sink)> GetEnvironmentVariablesWithLogs( + IResource resource, IDistributedApplicationBuilder builder) { var env = new Dictionary(); - var context = new EnvironmentCallbackContext(builder.ExecutionContext, resource, env); + var sink = new TestSink(); + var logger = new TestLogger(string.Empty, sink, enabled: true); + var context = new EnvironmentCallbackContext(builder.ExecutionContext, resource, env) + { + Logger = logger + }; foreach (var callback in resource.Annotations.OfType()) { await callback.Callback(context).ConfigureAwait(false); } - return env; + return (env, sink); } private static string ResolveManifestExpression(object value) diff --git a/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs b/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs index 650dc2a581e..e92698282aa 100644 --- a/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs +++ b/tests/Aspire.Hosting.Blazor.Tests/GatewayConfigurationBuilderTests.cs @@ -323,7 +323,7 @@ public void EmitProxyConfiguration_DoesNotEmitOtlpProxy_WhenTelemetryDisabled() var configResponse = (IManifestExpressionProvider)env["ClientApps__store__ConfigResponse"]; var manifestExpression = configResponse.ValueExpression; - Assert.DoesNotContain("ASPIRE_OTLP_PATH_BASE", manifestExpression); + Assert.DoesNotContain("OTEL_EXPORTER_OTLP_ENDPOINT", manifestExpression); } [Fact] @@ -462,7 +462,7 @@ public void EmitProxyConfiguration_OtlpEndpoint_PrefersHttpsBaseUrl() // OTLP path base is emitted so the WASM client can resolve it against // the page's origin, avoiding cross-origin issues. - Assert.Contains("ASPIRE_OTLP_PATH_BASE", configJson); + Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", configJson); Assert.Contains("/_otlp", configJson); // Service discovery emits both schemes so the client can pick the right one.