Skip to content
Open
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 @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion playground/BlazorHosted/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion playground/BlazorStandalone/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/Aspire.Hosting.Blazor/Aspire.Hosting.Blazor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@
<PropertyGroup>
<MinCodeCoverage>0</MinCodeCoverage>
<EnablePackageValidation>false</EnablePackageValidation>
<!-- The minimum .NET version for Docker images used in the Blazor gateway publish pipeline.
Derived from the highest TFM in the repo (currently net10.0). The Gateway.cs file-based
app and the WASM client projects target this version, so published containers must use
matching SDK/runtime base images. -->
<BlazorGatewayDotNetImageTag>10.0</BlazorGatewayDotNetImageTag>
</PropertyGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>BlazorGatewayDotNetImageTag</_Parameter1>
<_Parameter2>$(BlazorGatewayDotNetImageTag)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
Expand Down
104 changes: 73 additions & 31 deletions src/Aspire.Hosting.Blazor/BlazorGatewayExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,7 +33,7 @@ public static class BlazorGatewayExtensions
[AspireExportIgnore(Reason = "Blazor gateway APIs are not yet stable for ATS export.")]
public static IResourceBuilder<ProjectResource> AddBlazorGateway(
this IDistributedApplicationBuilder builder,
string name)
[ResourceName] string name)
{
var gatewayPath = GetScriptPath("Gateway.cs");
var gateway = builder.AddCSharpApp(name, gatewayPath)
Expand Down Expand Up @@ -84,7 +80,7 @@ public static IResourceBuilder<ProjectResource> AddBlazorGateway(
[AspireExportIgnore(Reason = "Open generic type parameter TProject is not ATS-compatible.")]
public static IResourceBuilder<BlazorWasmAppResource> AddBlazorWasmProject<TProject>(
this IDistributedApplicationBuilder builder,
string name)
[ResourceName] string name)
where TProject : IProjectMetadata, new()
{
var metadata = new TProject();
Expand All @@ -109,7 +105,7 @@ public static IResourceBuilder<BlazorWasmAppResource> AddBlazorWasmProject<TProj
[AspireExportIgnore(Reason = "Blazor gateway APIs are not yet stable for ATS export.")]
public static IResourceBuilder<BlazorWasmAppResource> AddBlazorWasmApp(
this IDistributedApplicationBuilder builder,
string name,
[ResourceName] string name,
string projectPath)
{
var resolvedPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, projectPath));
Expand Down Expand Up @@ -201,8 +197,7 @@ internal static IResourceBuilder<ProjectResource> 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)
{
Expand Down Expand Up @@ -253,9 +248,15 @@ internal static IResourceBuilder<ProjectResource> WithBlazorApp(
// 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<ResourceLoggerService>();

GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl, resourceLoggerService);
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.");
}

GatewayConfigurationBuilder.EmitProxyConfiguration(context.EnvironmentVariables, registeredApps, gatewayEndpoint, httpGatewayEndpoint, httpOtlpEndpointUrl);
});
}

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -466,6 +467,19 @@ private static string GetScriptPath(string scriptName)
return scriptPath;
}

private const string AspireStorePathKey = "Aspire:Store:Path";

/// <summary>
/// Gets the Blazor-specific store path under the Aspire store directory.
/// </summary>
private static string GetBlazorStorePath(IDistributedApplicationBuilder builder)
{
var storePath = builder.Configuration[AspireStorePathKey]
?? builder.AppHostDirectory;

return Path.Combine(storePath, ".aspire", "blazor");
}

private static List<EndpointReferenceAnnotation> GetServiceDiscoveryReferences(IResource resource)
{
// EndpointReferenceAnnotation is added by WithReference and tracks which endpoint
Expand Down Expand Up @@ -637,30 +651,58 @@ private readonly struct ProjectInfo(string solutionRoot, string relativeProjectP
}

/// <summary>
/// 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.
/// </summary>
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<System.Reflection.AssemblyMetadataAttribute>()
.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";
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion src/Aspire.Hosting.Blazor/BlazorHostedExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;

#pragma warning disable ASPIREATS001 // AspireExportIgnore is experimental

Expand Down Expand Up @@ -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,
Expand All @@ -99,7 +107,6 @@ private static void EnsureEnvironmentCallback(
annotation.Services,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The OTLP misconfiguration warning was re-added at the call site in BlazorGatewayExtensions.cs (standalone gateway path), but the equivalent warning is missing here in the hosted Blazor path. When httpOtlpEndpointUrl is null and annotation.ProxyBlazorTelemetry is true, the user gets no diagnostic indicating that WASM client telemetry won't be forwarded.

The old ClientConfigValueProvider.LogOtlpWarningIfNeeded() covered both paths; now only the standalone gateway warns.

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.");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ensure there are tests for both scenarios that check a warning is logged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — added in 9759ffb:

  • \ProxyTelemetry_LogsWarning_WhenOtlpEndpointNotResolvable\ — verifies the warning fires when ProxyBlazorTelemetry is true but no HTTP OTLP endpoint is available.
  • \ProxyTelemetry_DoesNotLogWarning_WhenOtlpEndpointIsConfigured\ — verifies no spurious warning when the endpoint is properly configured.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the equivalent warning in the hosted path in 9759ffb. Both standalone and hosted paths now log when telemetry proxying is requested but no OTLP endpoint is resolvable.

annotation.ProxyBlazorTelemetry,
httpOtlpEndpointUrl,
context.Logger,
annotation.OtlpPrefix);
});
}
Expand Down
Loading
Loading