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
6 changes: 4 additions & 2 deletions playground/TestShop/AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16037",
//"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:16037",
"DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:16038",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17037",
"DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
}
Expand All @@ -21,7 +22,8 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16031",
//"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16031",
"DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:16032",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17031",
"DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
Expand Down
37 changes: 24 additions & 13 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,26 @@ public sealed class ResourceServiceClientCertificateOptions
// Don't set values after validating/parsing options.
public sealed class OtlpOptions
{
private Uri? _parsedEndpointUrl;
private Uri? _parsedGrpcEndpointUrl;
private Uri? _parsedHttpEndpointUrl;
private byte[]? _primaryApiKeyBytes;
private byte[]? _secondaryApiKeyBytes;

public string? PrimaryApiKey { get; set; }
public string? SecondaryApiKey { get; set; }
public OtlpAuthMode? AuthMode { get; set; }
public string? EndpointUrl { get; set; }
public string? GrpcEndpointUrl { get; set; }

public Uri GetEndpointUri()
public string? HttpEndpointUrl { get; set; }

public Uri? GetGrpcEndpointUri()
{
return _parsedGrpcEndpointUrl;
}

public Uri? GetHttpEndpointUri()
{
Debug.Assert(_parsedEndpointUrl is not null, "Should have been parsed during validation.");
return _parsedEndpointUrl;
return _parsedHttpEndpointUrl;
}

public byte[] GetPrimaryApiKeyBytes()
Expand All @@ -89,18 +96,22 @@ public byte[] GetPrimaryApiKeyBytes()

internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
{
if (string.IsNullOrEmpty(EndpointUrl))
if (string.IsNullOrEmpty(GrpcEndpointUrl) && string.IsNullOrEmpty(HttpEndpointUrl))
{
errorMessage = $"OTLP endpoint URL is not configured. Specify a {DashboardConfigNames.DashboardOtlpUrlName.EnvVarName} value.";
errorMessage = $"Neither OTLP/gRPC or OTLP/HTTP endpoint URLs are configured. Specify either a {DashboardConfigNames.DashboardOtlpGrpcUrlName.EnvVarName} or {DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName} value.";
return false;
}
else

if (!string.IsNullOrEmpty(GrpcEndpointUrl) && !Uri.TryCreate(GrpcEndpointUrl, UriKind.Absolute, out _parsedGrpcEndpointUrl))
{
if (!Uri.TryCreate(EndpointUrl, UriKind.Absolute, out _parsedEndpointUrl))
{
errorMessage = $"Failed to parse OTLP endpoint URL '{EndpointUrl}'.";
return false;
}
errorMessage = $"Failed to parse OTLP gRPC endpoint URL '{GrpcEndpointUrl}'.";
return false;
}

if (!string.IsNullOrEmpty(HttpEndpointUrl) && !Uri.TryCreate(HttpEndpointUrl, UriKind.Absolute, out _parsedHttpEndpointUrl))
{
errorMessage = $"Failed to parse OTLP HTTP endpoint URL '{HttpEndpointUrl}'.";
return false;
}

_primaryApiKeyBytes = PrimaryApiKey != null ? Encoding.UTF8.GetBytes(PrimaryApiKey) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ public PostConfigureDashboardOptions(IConfiguration configuration)
public void PostConfigure(string? name, DashboardOptions options)
{
// Copy aliased config values to the strongly typed options.
if (_configuration[DashboardConfigNames.DashboardOtlpUrlName.ConfigKey] is { Length: > 0 } otlpUrl)
if (_configuration[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] is { Length: > 0 } otlpGrpcUrl)
{
options.Otlp.EndpointUrl = otlpUrl;
options.Otlp.GrpcEndpointUrl = otlpGrpcUrl;
}
// Copy aliased config values to the strongly typed options.
if (_configuration[DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] is { Length: > 0 } otlpHttpUrl)
{
options.Otlp.HttpEndpointUrl = otlpHttpUrl;
}
if (_configuration[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] is { Length: > 0 } frontendUrls)
{
Expand Down
108 changes: 86 additions & 22 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
using Aspire.Dashboard.Components;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp;
using Aspire.Dashboard.Otlp.Grpc;
using Aspire.Dashboard.Otlp.Http;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Hosting;
using Microsoft.AspNetCore.Authentication.Certificate;
Expand All @@ -34,24 +36,27 @@ namespace Aspire.Dashboard;

public sealed class DashboardWebApplication : IAsyncDisposable
{
internal const string DashboardOtlpUrlDefaultValue = "http://localhost:18889";
internal const string DashboardUrlDefaultValue = "http://localhost:18888";

private readonly WebApplication _app;
private readonly ILogger<DashboardWebApplication> _logger;
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptionsMonitor;
private readonly IReadOnlyList<string> _validationFailures;
private Func<EndpointInfo>? _frontendEndPointAccessor;
private Func<EndpointInfo>? _otlpServiceEndPointAccessor;
private Func<EndpointInfo>? _otlpServiceGrpcEndPointAccessor;
private Func<EndpointInfo>? _otlpServiceHttpEndPointAccessor;

public Func<EndpointInfo> FrontendEndPointAccessor
{
get => _frontendEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet.");
}

public Func<EndpointInfo> OtlpServiceEndPointAccessor
public Func<EndpointInfo> OtlpServiceGrpcEndPointAccessor
{
get => _otlpServiceGrpcEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet.");
}

public Func<EndpointInfo> OtlpServiceHttpEndPointAccessor
{
get => _otlpServiceEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet.");
get => _otlpServiceHttpEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet.");
}

public IOptionsMonitor<DashboardOptions> DashboardOptionsMonitor => _dashboardOptionsMonitor;
Expand Down Expand Up @@ -107,8 +112,8 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =

ConfigureKestrelEndpoints(builder, dashboardOptions);

var browserHttpsPort = dashboardOptions.Frontend.GetEndpointUris().FirstOrDefault(IsHttps)?.Port;
var isAllHttps = browserHttpsPort is not null && IsHttps(dashboardOptions.Otlp.GetEndpointUri());
var browserHttpsPort = dashboardOptions.Frontend.GetEndpointUris().FirstOrDefault(IsHttpsOrNull)?.Port;
var isAllHttps = browserHttpsPort is not null && IsHttpsOrNull(dashboardOptions.Otlp.GetGrpcEndpointUri()) && IsHttpsOrNull(dashboardOptions.Otlp.GetHttpEndpointUri());
if (isAllHttps)
{
// Explicitly configure the HTTPS redirect port as we're possibly listening on multiple HTTPS addresses
Expand Down Expand Up @@ -136,6 +141,11 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
builder.Services.AddGrpc();
builder.Services.AddSingleton<TelemetryRepository>();
builder.Services.AddTransient<StructuredLogsViewModel>();

builder.Services.AddTransient<OtlpLogsService>();
builder.Services.AddTransient<OtlpTraceService>();
builder.Services.AddTransient<OtlpMetricsService>();

builder.Services.AddTransient<TracesViewModel>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<IOutgoingPeerResolver, ResourceOutgoingPeerResolver>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<IOutgoingPeerResolver, BrowserLinkOutgoingPeerResolver>());
Expand Down Expand Up @@ -185,10 +195,15 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =
}
}

if (_otlpServiceEndPointAccessor != null)
if (_otlpServiceGrpcEndPointAccessor != null)
{
// This isn't used by dotnet watch but still useful to have for debugging
_logger.LogInformation("OTLP server running at: {OtlpEndpointUri}", _otlpServiceEndPointAccessor().Address);
_logger.LogInformation("OTLP/gRPC listening on: {OtlpEndpointUri}", _otlpServiceGrpcEndPointAccessor().Address);
}
if (_otlpServiceHttpEndPointAccessor != null)
{
// This isn't used by dotnet watch but still useful to have for debugging
_logger.LogInformation("OTLP/HTTP listening on: {OtlpEndpointUri}", _otlpServiceHttpEndPointAccessor().Address);
}

if (_dashboardOptionsMonitor.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured)
Expand Down Expand Up @@ -257,10 +272,17 @@ public DashboardWebApplication(Action<WebApplicationBuilder>? configureBuilder =

_app.MapRazorComponents<App>().AddInteractiveServerRenderMode();

// OTLP HTTP services.
var httpEndpoint = dashboardOptions.Otlp.GetHttpEndpointUri();
if (httpEndpoint != null)
{
_app.MapHttpOtlpApi();
}

// OTLP gRPC services.
_app.MapGrpcService<OtlpMetricsService>();
_app.MapGrpcService<OtlpTraceService>();
_app.MapGrpcService<OtlpLogsService>();
_app.MapGrpcService<OtlpGrpcMetricsService>();
_app.MapGrpcService<OtlpGrpcTraceService>();
_app.MapGrpcService<OtlpGrpcLogsService>();

if (dashboardOptions.Frontend.AuthMode == FrontendAuthMode.BrowserToken)
{
Expand Down Expand Up @@ -338,8 +360,9 @@ private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardO
{
// A single endpoint is configured if URLs are the same and the port isn't dynamic.
var frontendUris = dashboardOptions.Frontend.GetEndpointUris();
var otlpUri = dashboardOptions.Otlp.GetEndpointUri();
var hasSingleEndpoint = frontendUris.Count == 1 && frontendUris[0] == otlpUri && otlpUri.Port != 0;
var otlpGrpcUri = dashboardOptions.Otlp.GetGrpcEndpointUri();
var otlpHttpUri = dashboardOptions.Otlp.GetHttpEndpointUri();
var hasSingleEndpoint = frontendUris.Count == 1 && IsSameOrNull(frontendUris[0], otlpGrpcUri) && IsSameOrNull(frontendUris[0], otlpHttpUri);

var initialValues = new Dictionary<string, string?>();
var browserEndpointNames = new List<string>(capacity: frontendUris.Count);
Expand All @@ -348,7 +371,15 @@ private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardO
{
// Translate high-level config settings such as DOTNET_DASHBOARD_OTLP_ENDPOINT_URL and ASPNETCORE_URLS
// to Kestrel's schema for loading endpoints from configuration.
AddEndpointConfiguration(initialValues, "Otlp", otlpUri.OriginalString, HttpProtocols.Http2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
if (otlpGrpcUri != null)
{
AddEndpointConfiguration(initialValues, "OtlpGrpc", otlpGrpcUri.OriginalString, HttpProtocols.Http2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}
if (otlpHttpUri != null)
{
AddEndpointConfiguration(initialValues, "OtlpHttp", otlpHttpUri.OriginalString, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}

if (frontendUris.Count == 1)
{
browserEndpointNames.Add("Browser");
Expand All @@ -366,7 +397,9 @@ private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardO
}
else
{
AddEndpointConfiguration(initialValues, "Otlp", otlpUri.OriginalString, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
// At least one gRPC endpoint must be present.
var url = otlpGrpcUri?.OriginalString ?? otlpHttpUri?.OriginalString;
AddEndpointConfiguration(initialValues, "OtlpGrpc", url!, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}

static void AddEndpointConfiguration(Dictionary<string, string?> values, string endpointName, string url, HttpProtocols? protocols = null, bool requiredClientCertificate = false)
Expand All @@ -378,7 +411,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
values[$"Kestrel:Endpoints:{endpointName}:Protocols"] = protocols.ToString();
}

if (requiredClientCertificate)
if (requiredClientCertificate && IsHttpsOrNull(new Uri(url)))
{
values[$"Kestrel:Endpoints:{endpointName}:ClientCertificateMode"] = ClientCertificateMode.RequireCertificate.ToString();
}
Expand All @@ -405,9 +438,9 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
});
}

configurationLoader.Endpoint("Otlp", endpointConfiguration =>
configurationLoader.Endpoint("OtlpGrpc", endpointConfiguration =>
{
_otlpServiceEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration);
_otlpServiceGrpcEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration);
if (hasSingleEndpoint)
{
logger.LogDebug("Browser and OTLP accessible on a single endpoint.");
Expand All @@ -419,7 +452,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
"The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy.");
}

_frontendEndPointAccessor = _otlpServiceEndPointAccessor;
_frontendEndPointAccessor = _otlpServiceGrpcEndPointAccessor;
}

endpointConfiguration.ListenOptions.UseOtlpConnection();
Expand All @@ -433,6 +466,32 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
};
}
});

configurationLoader.Endpoint("OtlpHttp", endpointConfiguration =>
{
_otlpServiceHttpEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration);
if (hasSingleEndpoint)
{
logger.LogDebug("Browser and OTLP accessible on a single endpoint.");

if (!endpointConfiguration.IsHttps)
{
logger.LogWarning(
"The dashboard is configured with a shared endpoint for browser access and the OTLP service. " +
"The endpoint doesn't use TLS so browser access is only possible via a TLS terminating proxy.");
}

_frontendEndPointAccessor = _otlpServiceGrpcEndPointAccessor;
}

endpointConfiguration.ListenOptions.UseOtlpConnection();

if (endpointConfiguration.HttpsOptions.ClientCertificateMode == ClientCertificateMode.RequireCertificate)
{
// Allow invalid certificates when creating the connection. Certificate validation is done in the auth middleware.
endpointConfiguration.HttpsOptions.ClientCertificateValidation = (certificate, chain, sslPolicyErrors) => { return true; };
}
});
});

static Func<EndpointInfo> CreateEndPointAccessor(EndpointConfiguration endpointConfiguration)
Expand All @@ -452,6 +511,11 @@ static Func<EndpointInfo> CreateEndPointAccessor(EndpointConfiguration endpointC
}
}

private static bool IsSameOrNull(Uri frontendUri, Uri? otlpUrl)
{
return otlpUrl == null || (frontendUri == otlpUrl && otlpUrl.Port != 0);
}

private static void ConfigureAuthentication(WebApplicationBuilder builder, DashboardOptions dashboardOptions)
{
var authentication = builder.Services
Expand Down Expand Up @@ -616,7 +680,7 @@ public ValueTask DisposeAsync()
return _app.DisposeAsync();
}

private static bool IsHttps(Uri uri) => string.Equals(uri.Scheme, "https", StringComparison.Ordinal);
private static bool IsHttpsOrNull(Uri? uri) => uri == null || string.Equals(uri.Scheme, "https", StringComparison.Ordinal);

public static class FrontendAuthenticationDefaults
{
Expand Down
27 changes: 27 additions & 0 deletions src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcLogsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Authentication;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenTelemetry.Proto.Collector.Logs.V1;

namespace Aspire.Dashboard.Otlp.Grpc;

[Authorize(Policy = OtlpAuthorization.PolicyName)]
[SkipStatusCodePages]
public class OtlpGrpcLogsService : LogsService.LogsServiceBase
{
private readonly OtlpLogsService _logsService;

public OtlpGrpcLogsService(OtlpLogsService logsService)
{
_logsService = logsService;
}

public override Task<ExportLogsServiceResponse> Export(ExportLogsServiceRequest request, ServerCallContext context)
{
return Task.FromResult(_logsService.Export(request));
}
}
27 changes: 27 additions & 0 deletions src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcMetricsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Dashboard.Authentication;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenTelemetry.Proto.Collector.Metrics.V1;

namespace Aspire.Dashboard.Otlp.Grpc;

[Authorize(Policy = OtlpAuthorization.PolicyName)]
[SkipStatusCodePages]
public class OtlpGrpcMetricsService : MetricsService.MetricsServiceBase
{
private readonly OtlpMetricsService _metricsService;

public OtlpGrpcMetricsService(OtlpMetricsService metricsService)
{
_metricsService = metricsService;
}

public override Task<ExportMetricsServiceResponse> Export(ExportMetricsServiceRequest request, ServerCallContext context)
{
return Task.FromResult(_metricsService.Export(request));
}
}
Loading