From d0775948982da59a165d475fa573e862763b36e0 Mon Sep 17 00:00:00 2001 From: DotNet Bot Date: Thu, 28 Sep 2023 15:22:00 -0700 Subject: [PATCH 1/2] Merging changes from internal repository --- NuGet.config | 3 +- docs/getting-started.md | 50 ++- eng/Build.props | 1 - eng/Version.Details.xml | 28 +- eng/Versions.props | 10 +- .../Aspire.Hosting.Orchestration.props | 2 + .../Aspire.Hosting.Orchestration.targets | 11 + eng/dcppack/Sdk.in.targets | 5 + eng/dcppack/Sdk.props | 2 + eng/dcppack/UnixFilePermissions.xml | 6 + eng/dcppack/dcppack.csproj | 56 +++ eng/workloads/workloads.csproj | 29 +- global.json | 4 +- samples/BasketService/Program.cs | 2 +- samples/DevHost/Program.cs | 2 +- samples/MyFrontend/Program.cs | 2 + src/Aspire.Dashboard/Components/App.razor | 2 +- .../Components/Dialogs/LogDetailsDialog.razor | 96 ++++- .../Dialogs/LogDetailsDialog.razor.css | 26 ++ .../Dialogs/SpanDetailsDialog.razor | 160 ++++++++ .../Dialogs/SpanDetailsDialog.razor.css | 26 ++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Containers.razor | 54 +-- .../Components/Pages/Containers.razor.css | 4 - .../Components/Pages/Executables.razor | 42 ++- .../Components/Pages/Executables.razor.css | 4 - .../Components/Pages/Index.razor | 85 +++-- .../Components/Pages/Index.razor.css | 4 - .../Components/Pages/SemanticLogs.razor | 218 ++++++----- .../Components/Pages/SemanticLogs.razor.css | 14 - .../Components/Pages/TraceDetail.razor | 249 +++++++++++++ .../Components/Pages/Traces.razor | 185 +++++++++ .../Components/Pages/Traces.razor.css | 31 ++ .../DashboardWebApplication.cs | 2 + .../Model/Otlp/ApplicationViewModel.cs | 2 +- .../Model/Otlp/SpanWaterfallViewModel.cs | 16 + .../Model/SpanDetailsDialogViewModel.cs | 12 + .../Model/SpanPropertyViewModel.cs | 10 + .../Otlp/Model/ColorGenerator.cs | 91 +++++ .../Otlp/Model/DurationFormatter.cs | 60 +++ .../Otlp/Model/OtlpApplication.cs | 176 +-------- .../Otlp/Model/OtlpHelpers.cs | 65 +++- .../Otlp/Model/OtlpLogEntry.cs | 3 +- src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs | 63 ++++ .../Otlp/Model/OtlpSpanEvent.cs | 2 +- .../Otlp/Model/OtlpSpanKind.cs | 40 ++ .../Otlp/Model/OtlpSpanStatusCode.cs | 21 ++ src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs | 97 +++++ .../Otlp/Model/OtlpTraceCollection.cs | 42 +++ .../Otlp/Model/OtlpTraceScope.cs | 2 - .../Otlp/Model/OtlpTraceSpan.cs | 59 --- .../Otlp/Storage/GetLogsContext.cs | 2 +- .../Otlp/Storage/GetTracesContext.cs | 12 +- .../Otlp/Storage/Subscription.cs | 4 +- .../Otlp/Storage/TelemetryRepository.cs | 352 +++++++++++++++++- src/Aspire.Dashboard/wwwroot/css/app.css | 146 ++++++++ src/Aspire.Dashboard/wwwroot/js/app.js | 45 +-- .../Aspire.Hosting.Sdk.csproj | 2 +- .../SDK/BundledVersions.in.targets | 24 -- .../SDK/{Sdk.targets => Sdk.in.targets} | 24 +- src/Aspire.Hosting/Dcp/DcpHostService.cs | 13 +- src/Aspire.Hosting/Dcp/DcpRuntimeAttribute.cs | 31 -- src/Aspire.Hosting/Dcp/Locations.cs | 59 ++- .../Redis/RedisContainerBuilderExtensions.cs | 20 +- .../build/Aspire.Hosting.targets | 30 +- .../.template.config/template.json | 2 +- .../.template.config/template.json | 2 +- .../AspireRedisDistributedCacheExtensions.cs | 11 +- .../README.md | 48 ++- .../AspireRedisOutputCacheExtensions.cs | 11 +- .../README.md | 105 ++++-- .../AspireRedisExtensions.cs | 71 ++-- .../ConfigurationSchema.json | 2 +- .../Aspire.StackExchange.Redis/README.md | 105 +++--- .../StackExchangeRedisSettings.cs | 5 + .../WorkloadManifest.in.json | 99 +---- .../Aspire.Hosting.Ref.sfxproj | 22 -- .../Aspire.Hosting.Runtime.sfxproj | 32 -- .../DurationFormatterTests.cs | 72 ++++ .../TelemetryRepositoryTests.cs | 275 +++++++++++++- ...ireRedisDistributedCacheExtensionsTests.cs | 2 +- .../DistributedCacheConformanceTests.cs | 6 +- .../AspireRedisOutputCacheExtensionsTests.cs | 2 +- .../OutputCacheConformanceTests.cs | 6 +- .../AspireRedisExtensionsTests.cs | 65 +++- .../ConformanceTests.cs | 4 +- 86 files changed, 2944 insertions(+), 944 deletions(-) create mode 100644 eng/dcppack/Aspire.Hosting.Orchestration.props create mode 100644 eng/dcppack/Aspire.Hosting.Orchestration.targets create mode 100644 eng/dcppack/Sdk.in.targets create mode 100644 eng/dcppack/Sdk.props create mode 100644 eng/dcppack/UnixFilePermissions.xml create mode 100644 eng/dcppack/dcppack.csproj create mode 100644 src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor.css create mode 100644 src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Pages/Containers.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Pages/Executables.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor.css create mode 100644 src/Aspire.Dashboard/Components/Pages/TraceDetail.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/Traces.razor create mode 100644 src/Aspire.Dashboard/Components/Pages/Traces.razor.css create mode 100644 src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs create mode 100644 src/Aspire.Dashboard/Model/SpanDetailsDialogViewModel.cs create mode 100644 src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/DurationFormatter.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpSpanKind.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpSpanStatusCode.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs create mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpTraceCollection.cs delete mode 100644 src/Aspire.Dashboard/Otlp/Model/OtlpTraceSpan.cs delete mode 100644 src/Aspire.Hosting.Sdk/SDK/BundledVersions.in.targets rename src/Aspire.Hosting.Sdk/SDK/{Sdk.targets => Sdk.in.targets} (67%) delete mode 100644 src/Aspire.Hosting/Dcp/DcpRuntimeAttribute.cs delete mode 100644 src/WorkloadFrameworks/Aspire.Hosting.Ref.sfxproj delete mode 100644 src/WorkloadFrameworks/Aspire.Hosting.Runtime.sfxproj create mode 100644 tests/Aspire.Dashboard.Tests/DurationFormatterTests.cs diff --git a/NuGet.config b/NuGet.config index d48b3d77bd9..4f543e57b22 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,4 +1,4 @@ - + @@ -37,6 +37,7 @@ + diff --git a/docs/getting-started.md b/docs/getting-started.md index ccbcc65c7cb..de769e5c4cf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,17 +13,41 @@ ## Install .NET 8 RC2 1. Add the NuGet feed for .NET 8 - https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet8/nuget/v3/index.json (https://github.com/dotnet/installer#installers-and-binaries) -2. [Install the RC2 build](https://github.com/dotnet/installer#table) +2. Install the .NET 8 RC2 SDK version 8.0.100-rc.2.23472.8 or newer. + 1. [Windows x64 link](https://dotnetbuilds.azureedge.net/public/Sdk/8.0.100-rc.2.23472.8/dotnet-sdk-8.0.100-rc.2.23472.8-win-x64.exe) + 2. [Linux x64 link](https://dotnetbuilds.azureedge.net/public/Sdk/8.0.100-rc.2.23472.8/dotnet-sdk-8.0.100-rc.2.23472.8-linux-x64.tar.gz) + 3. [OSX x64 link](https://dotnetbuilds.azureedge.net/public/Sdk/8.0.100-rc.2.23472.8/dotnet-sdk-8.0.100-rc.2.23472.8-osx-x64.tar.gz) ## Install Docker Desktop 1. https://www.docker.com/ -## Download DCP application orchestrator +## Install the Aspire dotnet workload -1. [Download the orchestrator from here](https://microsoft-my.sharepoint.com/:f:/p/karolz/EoSAlHwu_OVDn3dBWW2D7hUBWOBzON4CeetcWOjiVW4JaQ?e=r5Bqzs) -2. Unzip it into your user profile folder (typically `c:\Users\` on Windows). The result should be that you have a ".dcp" folder in your profile, with dcp.exe inside. - - (and yes, we are working on a better setup story). +1. The RC2 SDK is aware that the Aspire workload exists, but the real manifest is not installed by default. In order to install it, you'll need to update the workload in a directory that has a NuGet.config[^3] with the right feeds configured[^2] so that it can pull the latest manifest. Once you have created the NuGet.config file in your working directory, then you need to run the following command[^1]: + + ```shell + dotnet workload update --skip-sign-check --interactive + ``` + +2. The above command will update the Aspire manifest in your RC2 build, meaning it will already be setup for command-line (VS support is coming soon) In-product acquisition (IPA) of the Aspire workload. In order to manually install the workload, you can run the following command[^1]: + + ```shell + dotnet workload install aspire --skip-sign-check --interactive + ``` + +[^1]: The `--skip-sign-check` flag is required because the packages we build out of the Aspire repo are not yet signed. +[^2]: If you want to create a separate NuGet.config instead, these are the contents you need: + ```xml + + + + + + + + ``` +[^3]: If you don't want to create a NuGet.config file, you should also be able to run the `update` and `install` commands using the following extra argument: `--source https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/v3/index.json`. # Create a new Project @@ -43,6 +67,22 @@ return await app.RunAsync(); 4. Look in the Terminal window to see which port the application is running on +# Create a new Project from the command line using workload templates + +- To create an empty Aspire project[^3], run the following command:: + +```shell + dotnet new aspire +``` + +- To create an Aspire project using the Starter template, run the following command: + +```shell + dotnet new aspire-starter +``` + +[^3]: In order for these commands to work, you must have already installed the Aspire workload by following the steps in #Install-the-Aspire-dotnet-workload section. + # Run the Aspire eShopLite sample ## Enable Azure ServiceBus (optional) diff --git a/eng/Build.props b/eng/Build.props index 32e325a9b20..7893e35908a 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -7,6 +7,5 @@ - diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 0abbf1b61ff..031057a974b 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -3,34 +3,34 @@ - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff - + https://github.com/dotnet/arcade - 4665b3d04e1da3796b965c3c3e3b97f55c449a6e + 1d451c32dda2314c721adbf8829e1c0cd4e681ff diff --git a/eng/Versions.props b/eng/Versions.props index 276cde0d0df..03ba3fdc00a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -11,12 +11,12 @@ 8.0.100-rc.1.23422.1 - <_DcpVersion>0.1.29-rc.3 + <_DcpVersion>0.1.30-rc.4 8.0.0-rc.1.23419.3 13.3.8825-net8-rc1 - 8.0.0-beta.23371.1 - 8.0.0-beta.23371.1 - 8.0.0-beta.23371.1 - 8.0.0-beta.23371.1 + 8.0.0-beta.23463.1 + 8.0.0-beta.23463.1 + 8.0.0-beta.23463.1 + 8.0.0-beta.23463.1 diff --git a/eng/dcppack/Aspire.Hosting.Orchestration.props b/eng/dcppack/Aspire.Hosting.Orchestration.props new file mode 100644 index 00000000000..c1df2220ddc --- /dev/null +++ b/eng/dcppack/Aspire.Hosting.Orchestration.props @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/eng/dcppack/Aspire.Hosting.Orchestration.targets b/eng/dcppack/Aspire.Hosting.Orchestration.targets new file mode 100644 index 00000000000..a910822e78b --- /dev/null +++ b/eng/dcppack/Aspire.Hosting.Orchestration.targets @@ -0,0 +1,11 @@ + + + + $([MSBuild]::NormalizePath($(MSBuildThisFileDirectory), '..', 'tools')) + $([MSBuild]::NormalizePath($(DcpDir), 'ext')) + $([MSBuild]::NormalizePath($(DcpExtensionsPath), 'bin')) + $([MSBuild]::NormalizePath($(DcpDir), 'dcp')) + $(DcpCliPath).exe + + + \ No newline at end of file diff --git a/eng/dcppack/Sdk.in.targets b/eng/dcppack/Sdk.in.targets new file mode 100644 index 00000000000..f5beae68980 --- /dev/null +++ b/eng/dcppack/Sdk.in.targets @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/eng/dcppack/Sdk.props b/eng/dcppack/Sdk.props new file mode 100644 index 00000000000..c1df2220ddc --- /dev/null +++ b/eng/dcppack/Sdk.props @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/eng/dcppack/UnixFilePermissions.xml b/eng/dcppack/UnixFilePermissions.xml new file mode 100644 index 00000000000..a643845a6a1 --- /dev/null +++ b/eng/dcppack/UnixFilePermissions.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/eng/dcppack/dcppack.csproj b/eng/dcppack/dcppack.csproj new file mode 100644 index 00000000000..46ad03f2609 --- /dev/null +++ b/eng/dcppack/dcppack.csproj @@ -0,0 +1,56 @@ + + + + + + + net8.0 + true + true + true + false + $(ArtifactsShippingPackagesDir) + + + + win-x64 + $([System.String]::Copy('$(DcpRuntime)').Replace('win-', 'windows-').Replace('osx-', 'darwin-').Replace('-x86', '-386').Replace('-x64', '-amd64')) + Windows + Unix + + + + Aspire.Hosting.Orchestration.$(DcpRuntime) + .NET Aspire Orchestration Dependencies + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/eng/workloads/workloads.csproj b/eng/workloads/workloads.csproj index 0c435bd598f..8a3a72b802d 100644 --- a/eng/workloads/workloads.csproj +++ b/eng/workloads/workloads.csproj @@ -36,16 +36,16 @@ - + - - - - - - - - + <_DcpRuntimes Include="win-x86" /> + <_DcpRuntimes Include="win-x64" /> + <_DcpRuntimes Include="win-arm64" /> + <_DcpRuntimes Include="linux-x64" /> + <_DcpRuntimes Include="linux-arm64" /> + <_DcpRuntimes Include="osx-x64" /> + <_DcpRuntimes Include="osx-arm64" /> + @@ -73,13 +73,7 @@ - - - <_DCPPackagesToCopy Include="$(NuGetPackageRoot)Microsoft.DeveloperControlPlane*\$(_DcpVersion)\*.nupkg" /> - - - - + - - - diff --git a/global.json b/global.json index 4fbfae2b725..b193237517d 100644 --- a/global.json +++ b/global.json @@ -8,8 +8,8 @@ "dotnet": "8.0.100-rc.2.23466.1" }, "msbuild-sdks": { - "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23451.1", - "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23451.1", + "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23463.1", + "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23463.1", "Microsoft.DotNet.SharedFramework.Sdk": "8.0.0-beta.23463.1", "Microsoft.Build.NoTargets": "3.7.0" } diff --git a/samples/BasketService/Program.cs b/samples/BasketService/Program.cs index 348e5a0ee96..5b904b5cbb5 100644 --- a/samples/BasketService/Program.cs +++ b/samples/BasketService/Program.cs @@ -5,7 +5,7 @@ builder.AddServiceDefaults(); builder.Services.AddGrpc(); -builder.AddRedis(); +builder.AddRedis("basketCache"); builder.Services.AddTransient(); // When running for Development, don't fail at startup if the developer hasn't configured ServiceBus yet. diff --git a/samples/DevHost/Program.cs b/samples/DevHost/Program.cs index 3c248ace907..d50c00c1b23 100644 --- a/samples/DevHost/Program.cs +++ b/samples/DevHost/Program.cs @@ -10,7 +10,7 @@ .WithServiceBinding(containerPort: 3000, name: "grafana-http", scheme: "http"); var postgres = builder.AddPostgresContainer("postgres"); -var redis = builder.AddRedisContainer("redis"); +var redis = builder.AddRedisContainer("basketCache"); var sql = builder.AddSqlServerContainer("sql"); var catalog = builder.AddProject() diff --git a/samples/MyFrontend/Program.cs b/samples/MyFrontend/Program.cs index fea32888551..16161b0288f 100644 --- a/samples/MyFrontend/Program.cs +++ b/samples/MyFrontend/Program.cs @@ -24,6 +24,8 @@ app.UseStaticFiles(); +app.UseAntiforgery(); + app.MapRazorComponents(); app.MapGet("/admin", (IConfiguration config) => diff --git a/src/Aspire.Dashboard/Components/App.razor b/src/Aspire.Dashboard/Components/App.razor index d1a27dc6fbc..1ffdb0eff41 100644 --- a/src/Aspire.Dashboard/Components/App.razor +++ b/src/Aspire.Dashboard/Components/App.razor @@ -13,7 +13,7 @@ - + diff --git a/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor index 8e911f2b3df..1b8a770a71e 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor @@ -1,23 +1,99 @@ @using Aspire.Dashboard.Model @using Microsoft.Fast.Components.FluentUI; @implements IDialogContentComponent> +@inject IJSRuntime JS - - - - - - +
+ + + + + + + + + + + @{ + var anchor = "name-" + context.Name; + } + + @preCopyText + + + + + + + + @{ + var anchor = "copy-" + context.Name; + } + + @preCopyText + + + + +
@code { - [CascadingParameter] - public FluentDialog? Dialog { get; set; } - [Parameter] public List Content { get; set; } = default!; - IQueryable? Items => Content.AsQueryable(); + private IQueryable? FilteredItems => + Content?.Where(vm => + vm.Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) || + vm.Value?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true + )?.AsQueryable(); + + private const string preCopyText = "Copy to clipboard"; + private const string postCopyText = "Copied!"; + + private string filter = ""; + + private GridSort nameSort = GridSort + .ByAscending(vm => vm.Name); + private GridSort valueSort = GridSort + .ByAscending(vm => vm.Value); + + private void HandleFilter(ChangeEventArgs args) + { + if (args.Value is string newFilter) + { + filter = newFilter; + } + } + + private void HandleClear(string? value) + { + filter = value ?? string.Empty; + } + + private async Task CopyTextToClipboardAsync(string? text, string id) + { + await JS.InvokeVoidAsync("copyTextToClipboard", id, text, preCopyText, postCopyText); + } } diff --git a/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor.css new file mode 100644 index 00000000000..58dad3386ac --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/LogDetailsDialog.razor.css @@ -0,0 +1,26 @@ +.GridContainer { + width: auto; + height: auto; + max-width: 1000px; + max-height: 500px; + min-width: 800px; + overflow-x: auto; + overflow-y: auto; +} + +::deep fluent-toolbar { + width: 100%; +} + +::deep .defaultHidden { + visibility: hidden; +} + +::deep .valueColumn:hover .defaultHidden, +::deep .nameColumn:hover .defaultHidden { + visibility: visible; +} + +.cellText { + flex-grow: 1; +} diff --git a/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor new file mode 100644 index 00000000000..86b330ca859 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor @@ -0,0 +1,160 @@ +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Otlp.Model +@using Microsoft.Fast.Components.FluentUI; +@implements IDialogContentComponent +@inject IJSRuntime JS + + +
+ + +
+ Service @Content.Span.Source.ApplicationName +
+ +
+ Duration @DurationFormatter.FormatDuration(Content.Span.Duration) +
+ +
+ Start Time @DurationFormatter.FormatDuration(Content.Span.StartTime - Content.Span.Trace.FirstSpan.StartTime) +
+ View Logs + + +
+ + + + + + + @{ + var anchor = "name-" + context.Name; + } + + @preCopyText + + + + + + + + @{ + var anchor = "copy-" + context.Name; + } + + @preCopyText + + + +

Application

+ + + + + + + @{ + var anchor = "name-" + context.Name; + } + + @preCopyText + + + + + + + + @{ + var anchor = "copy-" + context.Name; + } + + @preCopyText + + + +
+
+
+ + @code { + [Parameter] + public SpanDetailsDialogViewModel Content { get; set; } = default!; + + private IQueryable? FilteredItems => + Content.Properties.Where(vm => + vm.Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) || + vm.Value?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true + )?.AsQueryable(); + + private IQueryable? FilteredApplicationItems => + Content.Span.Source.AllProperties().Select(p => new SpanPropertyViewModel { Name = p.Key, Value = p.Value }) + .Where(vm => + vm.Name.Contains(filter, StringComparison.CurrentCultureIgnoreCase) || + vm.Value?.Contains(filter, StringComparison.CurrentCultureIgnoreCase) == true + )?.AsQueryable(); + + private const string preCopyText = "Copy to clipboard"; + private const string postCopyText = "Copied!"; + + private string filter = ""; + + private GridSort nameSort = GridSort + .ByAscending(vm => vm.Name); + private GridSort valueSort = GridSort + .ByAscending(vm => vm.Value); + + private void HandleFilter(ChangeEventArgs args) + { + if (args.Value is string newFilter) + { + filter = newFilter; + } + } + + private void HandleClear(string? value) + { + filter = value ?? string.Empty; + } + + private async Task CopyTextToClipboardAsync(string? text, string id) + { + await JS.InvokeVoidAsync("copyTextToClipboard", id, text, preCopyText, postCopyText); + } +} + diff --git a/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor.css b/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor.css new file mode 100644 index 00000000000..58dad3386ac --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/SpanDetailsDialog.razor.css @@ -0,0 +1,26 @@ +.GridContainer { + width: auto; + height: auto; + max-width: 1000px; + max-height: 500px; + min-width: 800px; + overflow-x: auto; + overflow-y: auto; +} + +::deep fluent-toolbar { + width: 100%; +} + +::deep .defaultHidden { + visibility: hidden; +} + +::deep .valueColumn:hover .defaultHidden, +::deep .nameColumn:hover .defaultHidden { + visibility: visible; +} + +.cellText { + flex-grow: 1; +} diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index eb1cfeba9c1..90cc4b80c56 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -11,6 +11,7 @@ + @* Have to use Style instead of Class because of https://github.com/microsoft/fluentui-blazor/issues/741 *@ diff --git a/src/Aspire.Dashboard/Components/Pages/Containers.razor b/src/Aspire.Dashboard/Components/Pages/Containers.razor index 2304cdaa3e8..3f9d1f56c55 100644 --- a/src/Aspire.Dashboard/Components/Pages/Containers.razor +++ b/src/Aspire.Dashboard/Components/Pages/Containers.razor @@ -19,31 +19,35 @@ AfterBindValue="HandleClear" slot="end" /> - - - - - - - - - - - - @string.Join(";", context.Ports.Select(e => e.ToString())) - - - View - - - View - - - No running containers found - +
+ + + + + + + + + + + + @string.Join(";", context.Ports.Select(e => e.ToString())) + + + View + + + View + + + +  No running containers found + + +
diff --git a/src/Aspire.Dashboard/Components/Pages/Containers.razor.css b/src/Aspire.Dashboard/Components/Pages/Containers.razor.css deleted file mode 100644 index b21a26e3333..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/Containers.razor.css +++ /dev/null @@ -1,4 +0,0 @@ -::deep fluent-toolbar, -::deep fluent-data-grid { - width: 100%; -} diff --git a/src/Aspire.Dashboard/Components/Pages/Executables.razor b/src/Aspire.Dashboard/Components/Pages/Executables.razor index 1c091269f94..ce54e483836 100644 --- a/src/Aspire.Dashboard/Components/Pages/Executables.razor +++ b/src/Aspire.Dashboard/Components/Pages/Executables.razor @@ -19,25 +19,29 @@ AfterBindValue="HandleClear" slot="end" /> - - - - - - - - - - - - View - - - No running executables found - +
+ + + + + + + + + + + + View + + + +  No running executables found + + +
diff --git a/src/Aspire.Dashboard/Components/Pages/Executables.razor.css b/src/Aspire.Dashboard/Components/Pages/Executables.razor.css deleted file mode 100644 index b21a26e3333..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/Executables.razor.css +++ /dev/null @@ -1,4 +0,0 @@ -::deep fluent-toolbar, -::deep fluent-data-grid { - width: 100%; -} diff --git a/src/Aspire.Dashboard/Components/Pages/Index.razor b/src/Aspire.Dashboard/Components/Pages/Index.razor index 27af7d84e17..d3c3227e110 100644 --- a/src/Aspire.Dashboard/Components/Pages/Index.razor +++ b/src/Aspire.Dashboard/Components/Pages/Index.razor @@ -19,51 +19,62 @@ AfterBindValue="HandleClear" slot="end" /> - - - - - - - - - - - - - @if (context.ExpectedEndpointCount == 0 && context.Endpoints.Count == 0) - { - N/A - } - else - { - foreach (ServiceEndpoint endpoint in context.Endpoints.OrderBy(e => e.Address)) +
+ + + + + + + + + + + + + @* If we have no endpoints, and the app isn't running anymore or we're not expecting any, then just say None *@ + @if (context.Endpoints.Count == 0 && (context.State == FinishedState || context.ExpectedEndpointCount == 0)) { - @endpoint.Address + None } - if (context.ExpectedEndpointCount > context.Endpoints.Count) + else { - Starting... + @* If we have any, regardless of the state, go ahead and display them *@ + foreach (ServiceEndpoint endpoint in context.Endpoints.OrderBy(e => e.Address)) + { + @endpoint.Address + } + @* If we're expecting more, say Starting..., unless the app isn't running anymore *@ + if (context.State != FinishedState && context.ExpectedEndpointCount > context.Endpoints.Count) + { + Starting... + } } - } - - - - View - - - View - - - No running projects found - + + + + View + + + View + + + +  No running projects found + + +
@code { + // Ideally we'd be pulling this from Aspire.Hosting.Dcp.Model.ExecutableStates, + // but unfortunately the reference goes the other way + private const string FinishedState = "Finished"; + private readonly Dictionary projectsMap = new(); private readonly CancellationTokenSource watchTaskCancellationTokenSource = new(); private string filter = ""; diff --git a/src/Aspire.Dashboard/Components/Pages/Index.razor.css b/src/Aspire.Dashboard/Components/Pages/Index.razor.css index e3079ae5983..293bc16adae 100644 --- a/src/Aspire.Dashboard/Components/Pages/Index.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Index.razor.css @@ -6,7 +6,3 @@ margin-right: 0.8em; } -::deep fluent-toolbar, -::deep fluent-data-grid { - width: 100%; -} diff --git a/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor b/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor index f009b1215c2..a6c101ed3b2 100644 --- a/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor @@ -11,57 +11,55 @@ @implements IDisposable

Semantic Logs

- -@if (_applications is null) -{ -

Loading...

-} -else -{ -
- - @if (_applications.Count == 0) +
+ + + + Filters: + @if (_logFilters.Count == 0) { -
No OpenTelemetry applications found.
+ No Filters } else { - - - - Filters: - @if (_logFilters.Count == 0) - { - No Filters - } - else - { - foreach (var filter in _logFilters) - { - @filter.FilterText - } - } - - -
- - - - - - - - View - + foreach (var filter in _logFilters) + { + @filter.FilterText + } + } + + +
+ + + + + @context.Application.ApplicationName + + + + + + + @if (!string.IsNullOrEmpty(context.TraceId)) + { + + @OtlpHelpers.ToShortenedId(context.TraceId) + + } + + + View + + + +  No semantic logs found +
@@ -71,10 +69,12 @@ else } @code { + private static readonly ApplicationViewModel AllApplication = new ApplicationViewModel { Id = null, Name = "(All)" }; + private readonly List _logFilters = new(); private TotalItemsFooter totalItemsFooter = default!; private List? _applications; - private ApplicationViewModel? _selectedApplication; + private ApplicationViewModel _selectedApplication = AllApplication; private Subscription? _applicationsSubscription; private Subscription? _logsSubscription; private int _totalItemCount; @@ -89,20 +89,23 @@ else [Inject] public required IDialogService DialogService { get; set; } + [Parameter] + [SupplyParameterFromQuery] + public string? TraceId { get; set; } + + [Parameter] + [SupplyParameterFromQuery] + public string? SpanId { get; set; } + private ValueTask> GetData(GridItemsProviderRequest request) { - if (_selectedApplication is null) - { - return ValueTask.FromResult(new GridItemsProviderResult()); - } - var logs = TelemetryRepository.GetLogs(new GetLogsContext - { - ApplicationServiceId = _selectedApplication.Id, - StartIndex = request.StartIndex, - Count = request.Count, - Filters = _logFilters - }); + { + ApplicationServiceId = _selectedApplication.Id, + StartIndex = request.StartIndex, + Count = request.Count, + Filters = _logFilters + }); // Updating the total item count as a field doesn't work because it isn't updated with the grid. // The workaround is to put the count inside a control and explicitly update and refresh the control. @@ -117,43 +120,47 @@ else protected override async Task OnInitializedAsync() { + if (!string.IsNullOrEmpty(TraceId)) + { + _logFilters.Add(new LogFilter { Field = "TraceId", Condition = FilterCondition.Equals, Value = TraceId }); + } + if (!string.IsNullOrEmpty(SpanId)) + { + _logFilters.Add(new LogFilter { Field = "SpanId", Condition = FilterCondition.Equals, Value = SpanId }); + } + await UpdateApplicationsAsync(); - _applicationsSubscription = TelemetryRepository.OnNewApplications(() => InvokeAsync(UpdateApplicationsAsync)); + _applicationsSubscription = TelemetryRepository.OnNewApplications(() => InvokeAsync(async () => + { + await UpdateApplicationsAsync(); + StateHasChanged(); + })); } - private async Task UpdateApplicationsAsync() + private Task UpdateApplicationsAsync() { _applications = TelemetryRepository.GetApplications().Select(a => new ApplicationViewModel { Id = a.InstanceId, Name = a.ApplicationName }).ToList(); - if (_selectedApplication == null && _applications.Count > 0) - { - _selectedApplication = _applications.SingleOrDefault(e => e.Id == ApplicationInstanceId) ?? _applications[0]; - _applicationChanged = true; - await UpdateDataAsync(); - } + _applications.Insert(0, AllApplication); + _selectedApplication = _applications.SingleOrDefault(e => e.Id == ApplicationInstanceId) ?? AllApplication; + + return Task.CompletedTask; } - private async Task HandleSelectedApplicationChangedAsync() + private Task HandleSelectedApplicationChangedAsync() { - if (_selectedApplication is not null) - { - NavigationManager.NavigateTo($"/SemanticLogs/{_selectedApplication.Id}"); - await UpdateDataAsync(); - } + NavigationManager.NavigateTo($"/SemanticLogs/{_selectedApplication.Id}"); + _applicationChanged = true; + return Task.CompletedTask; } - private async Task UpdateDataAsync() + private void UpdateSubscription() { // Subscribe to updates. - if (_logsSubscription is null || _logsSubscription.ApplicationId != _selectedApplication?.Id) + if (_logsSubscription is null || _logsSubscription.ApplicationId != _selectedApplication.Id) { _logsSubscription?.Dispose(); - if (_selectedApplication is not null) - { - _logsSubscription = TelemetryRepository.OnNewLogs(_selectedApplication.Id, () => InvokeAsync(UpdateDataAsync)); - } + _logsSubscription = TelemetryRepository.OnNewLogs(_selectedApplication.Id, () => InvokeAsync(StateHasChanged)); } - - await InvokeAsync(StateHasChanged); } private async Task OnShowProperties(OtlpLogEntry entry) @@ -163,24 +170,22 @@ else .ToList(); var parameters = new DialogParameters - { - Title = "Log Entry Details", - Width = "500px", - Height = "auto", - TrapFocus = true, - Modal = true, - PrimaryAction = "Close", - PrimaryActionEnabled = true, - SecondaryAction = null, - }; - await DialogService.ShowPanelAsync(entryProperties, parameters); + { + Title = "Log Entry Details", + Width = "auto", + Height = "auto", + TrapFocus = true, + Modal = true, + PrimaryAction = "Close", + PrimaryActionEnabled = true, + SecondaryAction = null, + }; + await DialogService.ShowDialogAsync(entryProperties, parameters); } private async Task OpenFilterAsync(LogFilter? entry) { - var logPropertyKeys = _selectedApplication is not null - ? TelemetryRepository.GetLogPropertyKeys(_selectedApplication.Id) ?? new List() - : new List(); + var logPropertyKeys = TelemetryRepository.GetLogPropertyKeys(_selectedApplication.Id); var title = entry is not null ? "Edit Filter" : "Add Filter"; var parameters = new DialogParameters @@ -199,7 +204,7 @@ else await DialogService.ShowPanelAsync(data, parameters); } - private async Task HandleFilterDialog(DialogResult result) + private Task HandleFilterDialog(DialogResult result) { if (result.Data is FilterDialogResult filterResult && filterResult.Filter is LogFilter filter) { @@ -213,28 +218,17 @@ else } } - await UpdateDataAsync(); + return Task.CompletedTask; } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) + if (_applicationChanged) { - if (_selectedApplication is not null) - { - if (_applicationChanged) - { - await JS.InvokeVoidAsync("switchLogsApplication"); - _applicationChanged = false; - } - await JS.InvokeVoidAsync("scollToLogsEnd"); - } + await JS.InvokeVoidAsync("switchLogsApplication"); + _applicationChanged = false; } - } - - private string FormatTimeStamp(DateTime timestamp) - { - return timestamp.ToLocalTime().ToString("h:mm:ss.fff tt"); + await JS.InvokeVoidAsync("scollToLogsEnd"); } public void Dispose() diff --git a/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor.css b/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor.css deleted file mode 100644 index 88a0070e108..00000000000 --- a/src/Aspire.Dashboard/Components/Pages/SemanticLogs.razor.css +++ /dev/null @@ -1,14 +0,0 @@ -::deep fluent-toolbar, -::deep fluent-data-grid { - width: 100%; -} - -.SemanticLogsOverflow { - /* - Height of the browser - static height for other content - TODO: Is there a better way to do this? - */ - height: calc(100vh - 225px); - width: 100%; - overflow: auto; -} diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor new file mode 100644 index 00000000000..518ea5a21d1 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -0,0 +1,249 @@ +@page "/Traces/Trace/{traceId}" +@page "/Traces/Trace/{traceId}/Span/{spanId}" + +@using Aspire.Dashboard.Components.Dialogs +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Model.Otlp +@using Aspire.Dashboard.Otlp.Model +@using Aspire.Dashboard.Otlp.Storage +@using System.Diagnostics + +

@(_trace.FirstSpan.Source.ApplicationName): @_trace.FirstSpan.Name

+@_trace.TraceId.Substring(0, 7) + +
+ + +
+ Trace Start @_trace.FirstSpan.StartTime.ToString("MMMM d yyyy"), @OtlpHelpers.FormatTimeStamp(_trace.FirstSpan.StartTime) +
+ +
+ Duration @DurationFormatter.FormatDuration(_trace.Duration) +
+ +
+ Services @_trace.Spans.GroupBy(s => s.Source).Count() +
+ +
+ Depth @_maxDepth +
+ +
+ Total Spans @_trace.Spans.Count +
+ View Logs + + Back +
+ + + + + @if (context.Span.Status == OtlpSpanStatusCode.Error) + { + + } + @context.Span.Source.ApplicationName + @if (!string.IsNullOrEmpty(context.UninstrumentedPeer)) + { + + + @context.UninstrumentedPeer + + } + @context.Span.Name + + + + +
+
+ @DurationFormatter.FormatDuration(TimeSpan.Zero) + +
+ @DurationFormatter.FormatDuration(_trace.Duration / 4) + +
+ @DurationFormatter.FormatDuration(_trace.Duration / 4 * 2) + +
+ @DurationFormatter.FormatDuration(_trace.Duration / 4 * 3) + + @DurationFormatter.FormatDuration(_trace.Duration) +
+
+
+ +
+
+
+
+ @context.Span.Source.ApplicationName: @context.Span.Name + @DurationFormatter.FormatDuration(context.Span.Duration) +
+
+
+
+
+
+
+
+
+
+
+
+
+ +@code { + private OtlpTrace _trace = default!; + private OtlpSpan? _span; + private Subscription? _tracesSubscription; + + [Parameter] + public required string TraceId { get; set; } + + [Parameter] + public string? SpanId { get; set; } + + [Inject] + public required IDialogService DialogService { get; set; } + + [Inject] + public required TelemetryRepository TelemetryRepository { get; set; } + + private int _maxDepth; + + private ValueTask> GetData(GridItemsProviderRequest request) + { + var orderedSpans = new List(); + // There should be one root span but just in case, we'll add them all. + foreach (var rootSpan in _trace.Spans.Where(s => string.IsNullOrEmpty(s.ParentSpanId)).OrderBy(s => s.StartTime)) + { + AddSelfAndChildren(orderedSpans, rootSpan, depth: 1, CreateViewModel); + } + // Unparented spans. + foreach (var unparentedSpan in _trace.Spans.Where(s => !string.IsNullOrEmpty(s.ParentSpanId) && s.GetParentSpan() == null).OrderBy(s => s.StartTime)) + { + AddSelfAndChildren(orderedSpans, unparentedSpan, depth: 1, CreateViewModel); + } + + _maxDepth = orderedSpans.Max(s => s.Depth); + + return ValueTask.FromResult(new GridItemsProviderResult + { + Items = orderedSpans, + TotalItemCount = orderedSpans.Count + }); + + static void AddSelfAndChildren(List orderedSpans, OtlpSpan span, int depth, Func createViewModel) + { + orderedSpans.Add(createViewModel(span, depth)); + depth++; + + foreach (var child in span.GetChildSpans().OrderBy(s => s.StartTime)) + { + AddSelfAndChildren(orderedSpans, child, depth, createViewModel); + } + } + + static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth) + { + var traceStart = span.Trace.FirstSpan.StartTime; + var relativeStart = span.StartTime - traceStart; + var rootDuration = span.Trace.Duration.TotalMilliseconds; + + var leftOffset = relativeStart.TotalMilliseconds / rootDuration * 100; + var width = span.Duration.TotalMilliseconds / rootDuration * 100; + + // Figure out if the label is displayed to the left or right of the span. + // If the label position is based on whether more than half of the span is on the left or right side of the trace. + var labelIsRight = (relativeStart + span.Duration / 2) < (span.Trace.Duration / 2); + + // A span may indicate a call to another service but the service isn't instrumented. + var hasPeerService = span.Attributes.Any(a => a.Key == OtlpSpan.PeerServiceAttributeKey); + var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any(); + + var viewModel = new SpanWaterfallViewModel + { + Span = span, + LeftOffset = leftOffset, + Width = width, + Depth = depth, + LabelIsRight = labelIsRight, + UninstrumentedPeer = isUninstrumentedPeer ? OtlpHelpers.GetValue(span.Attributes, OtlpSpan.PeerServiceAttributeKey) : null + }; + return viewModel; + } + } + + protected override void OnParametersSet() + { + UpdateDetailViewData(); + } + + private void UpdateDetailViewData() + { + if (TraceId is not null) + { + var trace = TelemetryRepository.GetTrace(TraceId); + if (trace == null) + { + throw new InvalidOperationException($"Could not find trace with trace id {TraceId}."); + } + _trace = trace; + if (_tracesSubscription is null || _tracesSubscription.ApplicationId != _trace.FirstSpan.Source.InstanceId) + { + _tracesSubscription?.Dispose(); + _tracesSubscription = TelemetryRepository.OnNewTraces(_trace.FirstSpan.Source.InstanceId, () => InvokeAsync(() => + { + UpdateDetailViewData(); + StateHasChanged(); + return Task.CompletedTask; + })); + } + if (SpanId is not null) + { + _span = _trace?.Spans.FirstOrDefault(s => s.SpanId.StartsWith(SpanId, StringComparison.Ordinal)); + } + } + } + + private string GetRowClass(SpanWaterfallViewModel viewModel) + { + return (viewModel.Span.SpanId == _span?.SpanId) ? "selected-span" : string.Empty; + } + + private async Task OnShowProperties(SpanWaterfallViewModel viewModel) + { + var entryProperties = viewModel.Span.Attributes + .Select(kvp => new SpanPropertyViewModel { Name = kvp.Key, Value = kvp.Value }) + .ToList(); + + var parameters = new DialogParameters + { + Title = viewModel.Span.Name, + Width = "auto", + Height = "auto", + TrapFocus = true, + Modal = true, + PrimaryAction = "Close", + PrimaryActionEnabled = true, + SecondaryAction = null, + }; + + var dialogViewModel = new SpanDetailsDialogViewModel + { + Span = viewModel.Span, + Properties = entryProperties + }; + + await DialogService.ShowDialogAsync(dialogViewModel, parameters); + } + + public void Dispose() + { + _tracesSubscription?.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor b/src/Aspire.Dashboard/Components/Pages/Traces.razor new file mode 100644 index 00000000000..e23b314ac72 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor @@ -0,0 +1,185 @@ +@page "/Traces/{applicationInstanceId?}" + +@using Aspire.Dashboard.Components.Dialogs +@using Aspire.Dashboard.Model +@using Aspire.Dashboard.Model.Otlp +@using Aspire.Dashboard.Otlp.Model +@using Aspire.Dashboard.Otlp.Storage +@using Microsoft.Fast.Components.FluentUI +@using System.Web +@inject NavigationManager NavigationManager +@inject IJSRuntime JS +@implements IDisposable + +

Traces

+ +
+ + + + +
+ + + + + @(context.FirstSpan.Source.ApplicationName): @context.FirstSpan.Name + @OtlpHelpers.ToShortenedId(context.TraceId) + + + + + @foreach (var item in context.Spans.GroupBy(s => s.Source).OrderBy(g => g.Key.ApplicationName)) + { + + + @if (item.Any(s => s.Status == OtlpSpanStatusCode.Error)) + { + + } + @item.Key.ApplicationName (@item.Count()) + + + } + + + + @($"+{another_name.ItemsOverflow.Count()}") + + + + + + + View + + + +  No traces found + + +
+ +
+
+ +@code { + private static readonly ApplicationViewModel AllApplication = new ApplicationViewModel { Id = null, Name = "(All)" }; + + private readonly List _logFilters = new(); + private TotalItemsFooter totalItemsFooter = default!; + private List? _applications; + private ApplicationViewModel _selectedApplication = AllApplication; + private Subscription? _applicationsSubscription; + private Subscription? _tracesSubscription; + private int _totalItemCount; + private TimeSpan _maxDuration; + private bool _applicationChanged; + + [Parameter] + public string? ApplicationInstanceId { get; set; } + + [Inject] + public required TelemetryRepository TelemetryRepository { get; set; } + + [Inject] + public required IDialogService DialogService { get; set; } + + private string GetRowStyle(OtlpTrace trace) + { + var percentage = 0.0; + if (_maxDuration != TimeSpan.Zero) + { + percentage = trace.Duration / _maxDuration * 100.0; + } + + return $"background: linear-gradient(to right, var(--duration-color) {percentage.ToString("0.##")}%, transparent {percentage.ToString("0.##")}%);"; + } + + private ValueTask> GetData(GridItemsProviderRequest request) + { + var result = TelemetryRepository.GetTraces(new GetTracesContext + { + ApplicationServiceId = _selectedApplication.Id, + StartIndex = request.StartIndex, + Count = request.Count + }); + var traces = result.PagedResult; + _maxDuration = result.MaxDuraiton; + + // Updating the total item count as a field doesn't work because it isn't updated with the grid. + // The workaround is to put the count inside a control and explicitly update and refresh the control. + totalItemsFooter.SetTotalItemCount(traces.TotalItemCount); + + return ValueTask.FromResult(new GridItemsProviderResult + { + Items = traces.Items, + TotalItemCount = _totalItemCount = traces.TotalItemCount + }); + } + + protected override async Task OnInitializedAsync() + { + await UpdateApplicationsAsync(); + _applicationsSubscription = TelemetryRepository.OnNewApplications(() => InvokeAsync(async () => + { + await UpdateApplicationsAsync(); + StateHasChanged(); + })); + } + + private Task UpdateApplicationsAsync() + { + _applications = TelemetryRepository.GetApplications().Select(a => new ApplicationViewModel { Id = a.InstanceId, Name = a.ApplicationName }).ToList(); + _applications.Insert(0, AllApplication); + _selectedApplication = _applications.SingleOrDefault(e => e.Id == ApplicationInstanceId) ?? AllApplication; + + return Task.CompletedTask; + } + + private Task HandleSelectedApplicationChangedAsync() + { + NavigationManager.NavigateTo($"/Traces/{_selectedApplication.Id}"); + _applicationChanged = true; + UpdateSubscription(); + + return Task.CompletedTask; + } + + private void UpdateSubscription() + { + // Subscribe to updates. + if (_tracesSubscription is null || _tracesSubscription.ApplicationId != _selectedApplication.Id) + { + _tracesSubscription?.Dispose(); + _tracesSubscription = TelemetryRepository.OnNewTraces(_selectedApplication.Id, () => InvokeAsync(StateHasChanged)); + } + } + + private Task SelectTraceAsync(OtlpTrace trace) + { + NavigationManager.NavigateTo($"/Traces/Trace/{trace.TraceId}"); + return Task.CompletedTask; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_applicationChanged) + { + await JS.InvokeVoidAsync("switchLogsApplication"); + _applicationChanged = false; + } + await JS.InvokeVoidAsync("scollToLogsEnd"); + } + + public void Dispose() + { + _applicationsSubscription?.Dispose(); + _tracesSubscription?.Dispose(); + } +} diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.css b/src/Aspire.Dashboard/Components/Pages/Traces.razor.css new file mode 100644 index 00000000000..e812a035a56 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.css @@ -0,0 +1,31 @@ + +.trace-id { + color: rgb(136, 136, 136); + padding-left: 0.5rem; + font-size: 12px; +} + +.trace-list-reset { + padding-left: 0px; + margin-bottom: 0px; + list-style: none; + display: inline-block; +} + +.trace-inline-block { + display: inline-block; +} + +.trace-tag { + display: flex; + align-items: center; + border: 1px solid rgb(217, 217, 217); + padding: 2px 7px; + margin-right: 7px; + font-size: 12px; + background: rgb(250, 250, 250) +} + +.trace-service-tag { + border-left-width: 15px; +} diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 5fb1e9da4ad..debf76ba2ec 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -92,6 +92,8 @@ public DashboardWebApplication(Action configureServices) _app.UseAuthorization(); + _app.UseAntiforgery(); + _app.MapRazorComponents().AddInteractiveServerRenderMode(); // OTLP gRPC services. diff --git a/src/Aspire.Dashboard/Model/Otlp/ApplicationViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/ApplicationViewModel.cs index 9da1f160ddb..0345c6c6131 100644 --- a/src/Aspire.Dashboard/Model/Otlp/ApplicationViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/ApplicationViewModel.cs @@ -6,5 +6,5 @@ namespace Aspire.Dashboard.Model.Otlp; public class ApplicationViewModel { public required string Name { get; init; } - public required string Id { get; init; } + public required string? Id { get; init; } } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs new file mode 100644 index 00000000000..21a223938f9 --- /dev/null +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -0,0 +1,16 @@ +// 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.Otlp.Model; + +namespace Aspire.Dashboard.Model.Otlp; + +public sealed class SpanWaterfallViewModel +{ + public required OtlpSpan Span { get; init; } + public required double LeftOffset { get; init; } + public required double Width { get; init; } + public required int Depth { get; init; } + public required bool LabelIsRight { get; init; } + public required string? UninstrumentedPeer { get; init; } +} diff --git a/src/Aspire.Dashboard/Model/SpanDetailsDialogViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsDialogViewModel.cs new file mode 100644 index 00000000000..64fc6388af3 --- /dev/null +++ b/src/Aspire.Dashboard/Model/SpanDetailsDialogViewModel.cs @@ -0,0 +1,12 @@ +// 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.Otlp.Model; + +namespace Aspire.Dashboard.Model; + +public sealed class SpanDetailsDialogViewModel +{ + public required OtlpSpan Span { get; init; } + public required List Properties { get; init; } +} diff --git a/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs b/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs new file mode 100644 index 00000000000..06ba43711b3 --- /dev/null +++ b/src/Aspire.Dashboard/Model/SpanPropertyViewModel.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Model; + +public sealed class SpanPropertyViewModel +{ + public required string Name { get; init; } + public required string Value { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs b/src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs new file mode 100644 index 00000000000..1560023b128 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/ColorGenerator.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Globalization; + +namespace Aspire.Dashboard.Otlp.Model; + +public sealed class GeneratedColor +{ + public required string Hex { get; init; } + public required int Red { get; init; } + public required int Green { get; init; } + public required int Blue { get; init; } +} + +public class ColorGenerator +{ + private static readonly string[] s_colorsHex = + [ + "#17B8BE", "#F8DCA1", "#B7885E", "#FFCB99", "#F89570", + "#829AE3", "#E79FD5", "#1E96BE", "#89DAC1", "#B3AD9E", + "#12939A", "#DDB27C", "#88572C", "#FF9833", "#EF5D28", + "#162A65", "#DA70BF", "#125C77", "#4DC19C", "#776E57" + ]; + public static readonly ColorGenerator Instance = new ColorGenerator(); + + private readonly List _colors; + private readonly ConcurrentDictionary> _colorIndexByKey; + private int _currentIndex; + + private ColorGenerator() + { + _colors = new List(); + _colorIndexByKey = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + _currentIndex = 0; + + foreach (var hex in s_colorsHex) + { + var rgb = GetHexRgb(hex); + _colors.Add(new GeneratedColor + { + Hex = hex, + Red = rgb.Red, + Green = rgb.Green, + Blue = rgb.Blue + }); + } + } + + private static (int Red, int Green, int Blue) GetHexRgb(string s) + { + if (s.Length != 7) + { + return (0, 0, 0); + } + + var r = int.Parse(s.AsSpan(1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var g = int.Parse(s.AsSpan(3, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var b = int.Parse(s.AsSpan(5, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + return (r, g, b); + } + + private int GetColorIndex(string key) + { + return _colorIndexByKey.GetOrAdd(key, k => + { + // GetOrAdd is run outside of the lock. + // Use lazy to ensure that the index is only calculated once for an app. + return new Lazy(() => + { + var i = _currentIndex; + _currentIndex = ++_currentIndex % _colors.Count; + return i; + }); + }).Value; + } + + public string GetColorHexByKey(string key) + { + var i = GetColorIndex(key); + return _colors[i].Hex; + } + + public void Clear() + { + _colorIndexByKey.Clear(); + _currentIndex = 0; + } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/DurationFormatter.cs b/src/Aspire.Dashboard/Otlp/Model/DurationFormatter.cs new file mode 100644 index 00000000000..bcd339b4713 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/DurationFormatter.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Otlp.Model; + +public static class DurationFormatter +{ + private sealed class UnitStep + { + public required string Unit { get; init; } + public required long Ticks { get; init; } + public bool IsDecimal { get; init; } + } + + private static readonly List s_unitSteps = new List + { + new UnitStep { Unit = "d", Ticks = TimeSpan.TicksPerDay }, + new UnitStep { Unit = "h", Ticks = TimeSpan.TicksPerHour }, + new UnitStep { Unit = "m", Ticks = TimeSpan.TicksPerMinute }, + new UnitStep { Unit = "s", Ticks = TimeSpan.TicksPerSecond, IsDecimal = true }, + new UnitStep { Unit = "ms", Ticks = TimeSpan.TicksPerMillisecond, IsDecimal = true }, + new UnitStep { Unit = "μs", Ticks = TimeSpan.TicksPerMicrosecond, IsDecimal = true }, + }; + + public static string FormatDuration(TimeSpan duration) + { + var (primaryUnit, secondaryUnit) = ResolveUnits(duration.Ticks); + var ofPrevious = primaryUnit.Ticks / secondaryUnit.Ticks; + var ticks = (double)duration.Ticks; + + if (primaryUnit.IsDecimal) + { + // If the unit is decimal based, display as a decimal + return $"{ticks / primaryUnit.Ticks:0.##}{primaryUnit.Unit}"; + } + + var primaryValue = Math.Floor(ticks / primaryUnit.Ticks); + var primaryUnitString = $"{primaryValue}{primaryUnit.Unit}"; + var secondaryValue = Math.Round((ticks / secondaryUnit.Ticks) % ofPrevious, MidpointRounding.AwayFromZero); + var secondaryUnitString = $"{secondaryValue}{secondaryUnit.Unit}"; + + return secondaryValue == 0 ? primaryUnitString : $"{primaryUnitString} {secondaryUnitString}"; + } + + private static (UnitStep, UnitStep) ResolveUnits(long ticks) + { + for (var i = 0; i < s_unitSteps.Count; i++) + { + var step = s_unitSteps[i]; + var result = i < s_unitSteps.Count - 1 && step.Ticks > ticks; + + if (!result) + { + return (step, i < s_unitSteps.Count - 1 ? s_unitSteps[i + 1] : step); + } + } + + return (s_unitSteps[^1], s_unitSteps[^1]); + } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs index a8a66fdf486..943c1bf6fb6 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpApplication.cs @@ -3,13 +3,10 @@ using System.Diagnostics; using System.Globalization; -using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using Microsoft.Extensions.Logging; -using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Metrics.V1; using OpenTelemetry.Proto.Resource.V1; -using OpenTelemetry.Proto.Trace.V1; namespace Aspire.Dashboard.Otlp.Model; @@ -23,15 +20,9 @@ public class OtlpApplication public string InstanceId { get; } public int Suffix { get; } - private readonly ReaderWriterLockSlim _logsLock = new(); - private readonly List _logs = new(); - private readonly HashSet _logPropertyKeys = new(); - private readonly ReaderWriterLockSlim _metricsLock = new(); private readonly Dictionary _meters = new(); - private readonly ReaderWriterLockSlim _traceSpanLock = new(); - private readonly Dictionary _traceScopes = new(); private readonly ILogger _logger; public KeyValuePair[] Properties { get; } @@ -68,130 +59,34 @@ public OtlpApplication(Resource resource, IReadOnlyDictionary $"{ApplicationName}-{Suffix}"; - - public string ShortApplicationName + public Dictionary AllProperties() { - get - { - var n = ApplicationName + Suffix.ToString(CultureInfo.InvariantCulture); - return n.Length <= 10 ? n : $"{ApplicationName.Left(3)}…{ApplicationName.Right(5)}{Suffix}"; - } - } + var props = new Dictionary(); + props.Add(SERVICE_NAME, ApplicationName); + props.Add(SERVICE_INSTANCE_ID, InstanceId); - public void AddLogs(AddContext context, RepeatedField scopeLogs) - { - _logsLock.EnterReadLock(); - - try + foreach (var kv in Properties) { - foreach (var sl in scopeLogs) - { - // Instrumentation Scope isn't commonly used for logs. - // Skip it for now until there is feedback that it has useful information. - - foreach (var record in sl.LogRecords) - { - try - { - var logEntry = new OtlpLogEntry(record, this); - _logs.Add(logEntry); - foreach (var kvp in logEntry.Properties) - { - _logPropertyKeys.Add(kvp.Key); - } - } - catch (Exception ex) - { - context.FailureCount++; - _logger.LogInformation(ex, "Error adding log entry."); - } - } - } - } - finally - { - _logsLock.ExitReadLock(); + props.TryAdd(kv.Key, kv.Value); } - } - - public PagedResult GetTraces(GetTracesContext context) - { - _logsLock.EnterReadLock(); - try - { - var results = _traceScopes.Values.AsEnumerable(); - - var items = GetItems(results, context.StartIndex, context.Count); - var count = results.Count(); - - return new PagedResult - { - Items = items, - TotalItemCount = count - }; - } - finally - { - _logsLock.ExitReadLock(); - } + return props; } - public PagedResult GetLogs(GetLogsContext context) - { - _logsLock.EnterReadLock(); - - try - { - var results = _logs.AsEnumerable(); - foreach (var filter in context.Filters) - { - results = filter.Apply(results); - } - - var items = GetItems(results, context.StartIndex, context.Count); - var count = results.Count(); - - return new PagedResult - { - Items = items, - TotalItemCount = count - }; - } - finally - { - _logsLock.ExitReadLock(); - } - } - - private static List GetItems(IEnumerable results, int startIndex, int? count) - { - var query = results.Skip(startIndex); - if (count != null) - { - query = query.Take(count.Value); - } - return query.ToList(); - } + public string UniqueApplicationName => $"{ApplicationName}-{Suffix}"; - public List GetLogPropertyKeys() + public string ShortApplicationName { - _logsLock.EnterReadLock(); - - try - { - return _logPropertyKeys.ToList(); - } - finally + get { - _logsLock.ExitReadLock(); + var n = ApplicationName + Suffix.ToString(CultureInfo.InvariantCulture); + return n.Length <= 10 ? n : $"{ApplicationName.Left(3)}…{ApplicationName.Right(5)}{Suffix}"; } } public void AddMetrics(AddContext context, RepeatedField scopeMetrics) { - _metricsLock.EnterReadLock(); + _metricsLock.EnterWriteLock(); try { @@ -236,50 +131,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr } finally { - _metricsLock.ExitReadLock(); - } - } - - internal void AddTraces(AddContext context, RepeatedField scopeSpans) - { - _traceSpanLock.EnterReadLock(); - - try - { - foreach (var scopeSpan in scopeSpans) - { - OtlpTraceScope? traceScope; - try - { - if (!_traceScopes.TryGetValue(scopeSpan.Scope.Name, out traceScope)) - { - traceScope = new OtlpTraceScope(scopeSpan.Scope); - } - } - catch (Exception ex) - { - context.FailureCount += scopeSpan.Spans.Count; - _logger.LogInformation(ex, "Error adding scope."); - continue; - } - - foreach (var span in scopeSpan.Spans) - { - try - { - traceScope.TraceSpans.Add(new OtlpTraceSpan(span, this, traceScope)); - } - catch (Exception ex) - { - context.FailureCount++; - _logger.LogInformation(ex, "Error adding span."); - } - } - } - } - finally - { - _traceSpanLock.ExitReadLock(); + _metricsLock.ExitWriteLock(); } } } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 518e4c7697c..dd6fc0bd51f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Text; +using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -27,9 +28,17 @@ public static class OtlpHelpers return null; } - public static string ToHexString(this ByteString bytes) + public static string ToShortenedId(string id) => + id.Length > 7 ? id[..7] : id; + + public static string FormatTimeStamp(DateTime timestamp) + { + return timestamp.ToLocalTime().ToString("h:mm:ss.fff tt", CultureInfo.CurrentCulture); + } + + public static string ToHexString(ReadOnlyMemory bytes) { - if (bytes is null or { Length: 0 }) + if (bytes.Length == 0) { return string.Empty; } @@ -46,6 +55,11 @@ public static string ToHexString(this ByteString bytes) }); } + public static string ToHexString(this ByteString bytes) + { + return ToHexString(bytes.Memory); + } + public static string GetString(this AnyValue value) => value.ValueCase switch { @@ -71,8 +85,19 @@ private static void ToCharsBuffer(byte value, Span buffer, int startingInd public static DateTime UnixNanoSecondsToDateTime(ulong unixTimeNanoSeconds) { - var ms = (long)unixTimeNanoSeconds / 1_000_000; - return DateTimeOffset.FromUnixTimeMilliseconds(ms).DateTime; + var ticks = NanoSecondsToTicks(unixTimeNanoSeconds); + + // Create a DateTime object for the Unix epoch (January 1, 1970) + var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + unixEpoch = unixEpoch.AddTicks(ticks); + + return unixEpoch; + } + + private static long NanoSecondsToTicks(ulong nanoSeconds) + { + const ulong nanosecondsPerTick = 100; // 100 nanoseconds per tick + return (long)(nanoSeconds / nanosecondsPerTick); } public static KeyValuePair[] ToKeyValuePairs(this RepeatedField attributes) @@ -83,7 +108,7 @@ public static KeyValuePair[] ToKeyValuePairs(this RepeatedField< } var values = new KeyValuePair[attributes.Count]; - for (int i = 0; i < attributes.Count; i++) + for (var i = 0; i < attributes.Count; i++) { var keyValue = attributes[i]; values[i] = new KeyValuePair(keyValue.Key, keyValue.Value.GetString()); @@ -125,4 +150,34 @@ public static string Left(this string value, int length) => public static string Right(this string value, int length) => value.Length <= length ? value : value.Substring(value.Length - length, length); + + public static PagedResult GetItems(IEnumerable results, int startIndex, int? count) + { + return GetItems(results, startIndex, count, null); + } + + public static PagedResult GetItems(IEnumerable results, int startIndex, int? count, Func? select) + { + var query = results.Skip(startIndex); + if (count != null) + { + query = query.Take(count.Value); + } + List items; + if (select != null) + { + items = query.Select(select).ToList(); + } + else + { + items = query.Cast().ToList(); + } + var totalItemCount = results.Count(); + + return new PagedResult + { + Items = items, + TotalItemCount = totalItemCount + }; + } } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index 0bebf8bf38b..44a343343e1 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Globalization; using Microsoft.Extensions.Logging; using OpenTelemetry.Proto.Logs.V1; @@ -90,8 +89,8 @@ public Dictionary AllProperties() var props = new Dictionary { { "Application", Application.UniqueApplicationName }, - { "Flags", Flags.ToString(CultureInfo.InvariantCulture) }, { "Severity", Severity.ToString() }, + { "Message", Message }, { "TraceId", TraceId }, { "SpanId", SpanId } }; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs new file mode 100644 index 00000000000..908bfbdc6f3 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Dashboard.Otlp.Model; + +/// +/// Represents a Span within an Operation (Trace) +/// +[DebuggerDisplay("SpanId = {SpanId}, ParentSpanId = {ParentSpanId}, TraceId = {Trace.TraceId}")] +public class OtlpSpan +{ + public const string PeerServiceAttributeKey = "peer.service"; + public const string SpanKindAttributeKey = "span.kind"; + + public string TraceId => Trace.TraceId; + public OtlpTrace Trace { get; } + public OtlpApplication Source { get; } + + public required string SpanId { get; init; } + public required string? ParentSpanId { get; init; } + public required string Name { get; init; } + public required OtlpSpanKind Kind { get; init; } + public required DateTime StartTime { get; init; } + public required DateTime EndTime { get; init; } + public required OtlpSpanStatusCode Status { get; init; } + public required string? StatusMessage { get; init; } + public required string? State { get; init; } + public required KeyValuePair[] Attributes { get; init; } + public required List Events { get; init; } + + public string ScopeName => Trace.TraceScope.ScopeName; + public string ScopeSource => Source.ApplicationName; + public TimeSpan Duration => EndTime - StartTime; + + public IEnumerable GetChildSpans() => Trace.Spans.Where(s => s.ParentSpanId == SpanId); + public OtlpSpan? GetParentSpan() => string.IsNullOrEmpty(ParentSpanId) ? null : Trace.Spans.Where(s => s.SpanId == ParentSpanId).FirstOrDefault(); + + public OtlpSpan(OtlpApplication application, OtlpTrace trace) + { + Source = application; + Trace = trace; + } + + public static OtlpSpan Clone(OtlpSpan item, OtlpTrace trace) + { + return new OtlpSpan(item.Source, trace) + { + SpanId = item.SpanId, + ParentSpanId = item.ParentSpanId, + Name = item.Name, + Kind = item.Kind, + StartTime = item.StartTime, + EndTime = item.EndTime, + Status = item.Status, + StatusMessage = item.StatusMessage, + State = item.State, + Attributes = item.Attributes, + Events = item.Events + }; + } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs index 9ae9b5fc792..53098329060 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanEvent.cs @@ -8,5 +8,5 @@ public class OtlpSpanEvent public required string Name { get; init; } public required DateTime Time { get; init; } public required KeyValuePair[] Attributes { get; init; } - public double TimeOffset(OtlpTraceSpan span) => (Time - span.StartTime).TotalMilliseconds; + public double TimeOffset(OtlpSpan span) => (Time - span.StartTime).TotalMilliseconds; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanKind.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanKind.cs new file mode 100644 index 00000000000..d31999e3492 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanKind.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Otlp.Model; + +public enum OtlpSpanKind +{ + /// + /// Unspecified. Do NOT use as default. + /// Implementations MAY assume SpanKind to be INTERNAL when receiving UNSPECIFIED. + /// + Unspecified = 0, + /// + /// Indicates that the span represents an internal operation within an application, + /// as opposed to an operation happening at the boundaries. Default value. + /// + Internal = 1, + /// + /// Indicates that the span covers server-side handling of an RPC or other + /// remote network request. + /// + Server = 2, + /// + /// Indicates that the span describes a request to some remote service. + /// + Client = 3, + /// + /// Indicates that the span describes a producer sending a message to a broker. + /// Unlike CLIENT and SERVER, there is often no direct critical path latency relationship + /// between producer and consumer spans. A PRODUCER span ends when the message was accepted + /// by the broker while the logical processing of the message might span a much longer time. + /// + Producer = 4, + /// + /// Indicates that the span describes consumer receiving a message from a broker. + /// Like the PRODUCER kind, there is often no direct critical path latency relationship + /// between producer and consumer spans. + /// + Consumer = 5, +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpanStatusCode.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanStatusCode.cs new file mode 100644 index 00000000000..4cad6c220bb --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpanStatusCode.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. + +namespace Aspire.Dashboard.Otlp.Model; + +public enum OtlpSpanStatusCode +{ + /// + /// The default status. + /// + Unset = 0, + /// + /// The Span has been validated by an Application developer or Operator to + /// have completed successfully. + /// + Ok = 1, + /// + /// The Span contains an error. + /// + Error = 2, +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs new file mode 100644 index 00000000000..38727e66e35 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Dashboard.Otlp.Model; + +[DebuggerDisplay("{DebuggerToString(),nq}")] +public class OtlpTrace +{ + private OtlpSpan? _rootSpan; + + public ReadOnlyMemory Key { get; } + public string TraceId { get; } + + public OtlpSpan FirstSpan => Spans[0]; // There should always be at least one span in a trace. + public OtlpSpan? RootSpan => _rootSpan; + public TimeSpan Duration + { + get + { + var start = FirstSpan.StartTime; + DateTime end = default; + foreach (var span in Spans) + { + if (span.EndTime > end) + { + end = span.EndTime; + } + } + return end - start; + } + } + + public List Spans { get; } = new List(); + + public OtlpTraceScope TraceScope { get; } + + public int CalculateDepth(OtlpSpan span) + { + var depth = 0; + var currentSpan = span; + while (currentSpan != null) + { + depth++; + currentSpan = currentSpan.GetParentSpan(); + } + return depth; + } + + public int CalculateMaxDepth() => Spans.Max(CalculateDepth); + + public void AddSpan(OtlpSpan span) + { + Spans.Add(span); + // TODO: Optimize to insert at the right position. + Spans.Sort(SpanStartDateComparer.Instance); + + if (string.IsNullOrEmpty(span.ParentSpanId)) + { + _rootSpan = span; + } + } + + public OtlpTrace(ReadOnlyMemory traceId, OtlpTraceScope traceScope) + { + Key = traceId; + TraceId = OtlpHelpers.ToHexString(traceId); + TraceScope = traceScope; + } + + public static OtlpTrace Clone(OtlpTrace trace) + { + var newTrace = new OtlpTrace(trace.Key, trace.TraceScope); + foreach (var item in trace.Spans) + { + newTrace.AddSpan(OtlpSpan.Clone(item, newTrace)); + } + + return newTrace; + } + + private string DebuggerToString() + { + return $@"TraceId = ""{TraceId}"", Spans = {Spans.Count}, StartTime = {FirstSpan.StartTime.ToLocalTime():h:mm:ss.fff tt}, Duration = {Duration}"; + } + + private sealed class SpanStartDateComparer : IComparer + { + public static readonly SpanStartDateComparer Instance = new SpanStartDateComparer(); + + public int Compare(OtlpSpan? x, OtlpSpan? y) + { + return x!.StartTime.CompareTo(y!.StartTime); + } + } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTraceCollection.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTraceCollection.cs new file mode 100644 index 00000000000..0c214e8aa72 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTraceCollection.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; + +namespace Aspire.Dashboard.Otlp.Model; + +public sealed class OtlpTraceCollection : KeyedCollection, OtlpTrace> +{ + public OtlpTraceCollection() : base(MemoryComparable.Instance, dictionaryCreationThreshold: 0) + { + + } + + protected override ReadOnlyMemory GetKeyForItem(OtlpTrace item) + { + return item.Key; + } + + private sealed class MemoryComparable : IEqualityComparer> + { + public static readonly MemoryComparable Instance = new(); + + public bool Equals(ReadOnlyMemory x, ReadOnlyMemory y) + { + return x.Span.SequenceEqual(y.Span); + } + + public int GetHashCode(ReadOnlyMemory obj) + { + unchecked + { + var hash = 17; + foreach (var value in obj.Span) + { + hash = hash * 23 + value.GetHashCode(); + } + return hash; + } + } + } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTraceScope.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTraceScope.cs index 2f44f0e5b59..894d9854d06 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpTraceScope.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTraceScope.cs @@ -17,8 +17,6 @@ public class OtlpTraceScope public string ServiceProperties => Properties.ConcatProperties(); - public List TraceSpans { get; } = new List(); - public OtlpTraceScope(InstrumentationScope scope) { ScopeName = scope.Name; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTraceSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTraceSpan.cs deleted file mode 100644 index d84430cc1cf..00000000000 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpTraceSpan.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Dashboard.Otlp.Model; - -/// -/// Represents a Span within an Operation (Trace) -/// -public class OtlpTraceSpan -{ - public string TraceId { get; init; } - public OtlpTraceScope TraceScope { get; init; } - public OtlpApplication Source { get; init; } - public string SpanId { get; init; } - public string? ParentSpanId { get; init; } - public string Name { get; init; } - public string Kind { get; init; } - public DateTime StartTime { get; init; } - public DateTime EndTime { get; init; } - public string? Status { get; init; } - public string? State { get; init; } - public KeyValuePair[] Attributes { get; } - public List Events { get; } = new(); - - public string ScopeName => TraceScope.ScopeName; - public string ScopeSource => Source.ApplicationName; - public TimeSpan Duration => EndTime - StartTime; - - public OtlpTraceSpan(OpenTelemetry.Proto.Trace.V1.Span s, OtlpApplication traceSource, OtlpTraceScope scope) - { - var id = s.SpanId?.ToHexString(); - if (id is null) - { - throw new ArgumentException("Span has no SpanId"); - } - SpanId = id; - ParentSpanId = s.ParentSpanId?.ToHexString(); - TraceId = s.TraceId.ToHexString(); - Source = traceSource; - TraceScope = scope; - Name = s.Name; - Kind = s.Kind.ToString(); - StartTime = OtlpHelpers.UnixNanoSecondsToDateTime(s.StartTimeUnixNano); - EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(s.EndTimeUnixNano); - Status = s.Status?.ToString(); - Attributes = s.Attributes.ToKeyValuePairs(); - State = s.TraceState; - - foreach (var e in s.Events) - { - Events.Add(new OtlpSpanEvent() - { - Name = e.Name, - Time = OtlpHelpers.UnixNanoSecondsToDateTime(e.TimeUnixNano), - Attributes = e.Attributes.ToKeyValuePairs() - }); - } - } -} diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs index e60f82ae013..1891206da0b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetLogsContext.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed class GetLogsContext { - public required string ApplicationServiceId { get; init; } + public required string? ApplicationServiceId { get; init; } public required int StartIndex { get; init; } public required int? Count { get; init; } public required List Filters { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetTracesContext.cs b/src/Aspire.Dashboard/Otlp/Storage/GetTracesContext.cs index da0170b2253..b1808e4ca18 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetTracesContext.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetTracesContext.cs @@ -1,11 +1,19 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Otlp.Model; + namespace Aspire.Dashboard.Otlp.Storage; public sealed class GetTracesContext { - public required string ApplicationServiceId { get; init; } + public required string? ApplicationServiceId { get; init; } public required int StartIndex { get; init; } public required int? Count { get; init; } } + +public sealed class GetTracesResult +{ + public required PagedResult PagedResult { get; init; } + public required TimeSpan MaxDuraiton { get; init; } +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs b/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs index ba5f0309633..e5b5275b6b9 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs @@ -8,9 +8,9 @@ public sealed class Subscription : IDisposable private readonly Func _callback; private readonly Action _unsubscribe; - public string ApplicationId { get; } + public string? ApplicationId { get; } - public Subscription(string applicationId, Func callback, Action unsubscribe) + public Subscription(string? applicationId, Func callback, Action unsubscribe) { ApplicationId = applicationId; _callback = callback; diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index 4b02750cc93..3c28ca45654 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -10,6 +10,7 @@ using OpenTelemetry.Proto.Metrics.V1; using OpenTelemetry.Proto.Resource.V1; using OpenTelemetry.Proto.Trace.V1; +using static OpenTelemetry.Proto.Trace.V1.Span.Types; namespace Aspire.Dashboard.Otlp.Storage; @@ -24,10 +25,18 @@ public class TelemetryRepository private readonly List _applicationSubscriptions = new(); private readonly List _logSubscriptions = new(); private readonly List _metricsSubscriptions = new(); - private readonly List _tracingSubscriptions = new(); + private readonly List _tracesSubscriptions = new(); private readonly ConcurrentDictionary _applications = new(); + private readonly ReaderWriterLockSlim _logsLock = new(); + private readonly List _logs = new(); + private readonly HashSet<(OtlpApplication Application, string PropertyKey)> _logPropertyKeys = new(); + + private readonly ReaderWriterLockSlim _tracesLock = new(); + private readonly Dictionary _traceScopes = new(); + private readonly OtlpTraceCollection _traces = new(); + public TelemetryRepository(IConfiguration config, ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); @@ -89,7 +98,7 @@ public Subscription OnNewApplications(Func callback) return AddSubscription(string.Empty, callback, _applicationSubscriptions); } - public Subscription OnNewLogs(string applicationId, Func callback) + public Subscription OnNewLogs(string? applicationId, Func callback) { return AddSubscription(applicationId, callback, _logSubscriptions); } @@ -99,12 +108,12 @@ public Subscription OnNewMetrics(string applicationId, Func callback) return AddSubscription(applicationId, callback, _metricsSubscriptions); } - public Subscription OnNewTracing(string applicationId, Func callback) + public Subscription OnNewTraces(string? applicationId, Func callback) { - return AddSubscription(applicationId, callback, _tracingSubscriptions); + return AddSubscription(applicationId, callback, _tracesSubscriptions); } - private Subscription AddSubscription(string applicationId, Func callback, List subscriptions) + private Subscription AddSubscription(string? applicationId, Func callback, List subscriptions) { Subscription? subscription = null; subscription = new Subscription(applicationId, callback, () => @@ -160,40 +169,163 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs continue; } - application.AddLogs(context, rl.ScopeLogs); + AddLogsCore(context, application, rl.ScopeLogs); } RaiseSubscriptionChanged(_logSubscriptions); } + public void AddLogsCore(AddContext context, OtlpApplication application, RepeatedField scopeLogs) + { + _logsLock.EnterWriteLock(); + + try + { + foreach (var sl in scopeLogs) + { + // Instrumentation Scope isn't commonly used for logs. + // Skip it for now until there is feedback that it has useful information. + + foreach (var record in sl.LogRecords) + { + try + { + var logEntry = new OtlpLogEntry(record, application); + _logs.Add(logEntry); + foreach (var kvp in logEntry.Properties) + { + _logPropertyKeys.Add((application, kvp.Key)); + } + } + catch (Exception ex) + { + context.FailureCount++; + _logger.LogInformation(ex, "Error adding log entry."); + } + } + } + + // TODO: Insert logs into the right location instead of sorting everything. + _logs.Sort((a, b) => a.TimeStamp.CompareTo(b.TimeStamp)); + } + finally + { + _logsLock.ExitWriteLock(); + } + } + public PagedResult GetLogs(GetLogsContext context) { - if (!_applications.TryGetValue(context.ApplicationServiceId, out var application)) + OtlpApplication? application = null; + if (context.ApplicationServiceId != null && !_applications.TryGetValue(context.ApplicationServiceId, out application)) { return PagedResult.Empty; } - return application.GetLogs(context); + _logsLock.EnterReadLock(); + + try + { + var results = _logs.AsEnumerable(); + if (application != null) + { + results = results.Where(l => l.Application == application); + } + + foreach (var filter in context.Filters) + { + results = filter.Apply(results); + } + + return OtlpHelpers.GetItems(results, context.StartIndex, context.Count); + } + finally + { + _logsLock.ExitReadLock(); + } } - public PagedResult GetTraces(GetTracesContext context) + public List GetLogPropertyKeys(string? applicationServiceId) { - if (!_applications.TryGetValue(context.ApplicationServiceId, out var application)) + _logsLock.EnterReadLock(); + + try + { + var applicationKeys = _logPropertyKeys.AsEnumerable(); + if (applicationServiceId != null) + { + applicationKeys = applicationKeys.Where(keys => keys.Application.InstanceId == applicationServiceId); + } + + var keys = applicationKeys.Select(keys => keys.PropertyKey).Distinct(); + return keys.ToList(); + } + finally { - return PagedResult.Empty; + _logsLock.ExitReadLock(); } + } + + public GetTracesResult GetTraces(GetTracesContext context) + { + _tracesLock.EnterReadLock(); - return application.GetTraces(context); + try + { + var results = _traces.AsEnumerable(); + if (context.ApplicationServiceId != null) + { + results = results.Where(t => HasApplication(t, context.ApplicationServiceId)); + } + + // Traces can be modified as new spans are added. Copy traces before returning results to avoid concurrency issues. + var copyFunc = static (OtlpTrace t) => OtlpTrace.Clone(t); + + var pagedResults = OtlpHelpers.GetItems(results, context.StartIndex, context.Count, copyFunc); + var maxDuration = pagedResults.TotalItemCount > 0 ? results.Max(r => r.Duration) : default; + + return new GetTracesResult + { + PagedResult = pagedResults, + MaxDuraiton = maxDuration + }; + } + finally + { + _tracesLock.ExitReadLock(); + } } - public List? GetLogPropertyKeys(string applicationServiceId) + public OtlpTrace? GetTrace(string traceId) { - if (!_applications.TryGetValue(applicationServiceId, out var application)) + _tracesLock.EnterReadLock(); + + try + { + var results = _traces.Where(t => t.TraceId.StartsWith(traceId, StringComparison.Ordinal)); + var trace = results.SingleOrDefault(); + return trace is not null ? OtlpTrace.Clone(trace) : null; + } + catch (Exception ex) { - return null; + throw new InvalidOperationException($"Multiple traces found with trace id '{traceId}'.", ex); } + finally + { + _tracesLock.ExitReadLock(); + } + } - return application.GetLogPropertyKeys(); + private static bool HasApplication(OtlpTrace t, string applicationServiceId) + { + foreach (var span in t.Spans) + { + if (span.Source.InstanceId == applicationServiceId) + { + return true; + } + } + return false; } internal void AddMetrics(AddContext context, RepeatedField resourceMetrics) @@ -218,7 +350,7 @@ internal void AddMetrics(AddContext context, RepeatedField reso RaiseSubscriptionChanged(_metricsSubscriptions); } - internal void AddTraces(AddContext context, RepeatedField resourceSpans) + public void AddTraces(AddContext context, RepeatedField resourceSpans) { foreach (var rs in resourceSpans) { @@ -234,9 +366,191 @@ internal void AddTraces(AddContext context, RepeatedField resourc continue; } - application.AddTraces(context, rs.ScopeSpans); + AddTracesCore(context, application, rs.ScopeSpans); } - RaiseSubscriptionChanged(_metricsSubscriptions); + RaiseSubscriptionChanged(_tracesSubscriptions); + } + + private static OtlpSpanStatusCode ConvertStatus(Status? status) + { + return status?.Code switch + { + Status.Types.StatusCode.Ok => OtlpSpanStatusCode.Ok, + Status.Types.StatusCode.Error => OtlpSpanStatusCode.Error, + Status.Types.StatusCode.Unset => OtlpSpanStatusCode.Unset, + _ => OtlpSpanStatusCode.Unset + }; + } + + private static OtlpSpanKind ConvertSpanKind(SpanKind? kind) + { + return kind switch + { + // Unspecified to Internal is intentional. + // "Implementations MAY assume SpanKind to be INTERNAL when receiving UNSPECIFIED." + SpanKind.Unspecified => OtlpSpanKind.Internal, + SpanKind.Client => OtlpSpanKind.Client, + SpanKind.Server => OtlpSpanKind.Server, + SpanKind.Producer => OtlpSpanKind.Producer, + SpanKind.Consumer => OtlpSpanKind.Consumer, + _ => OtlpSpanKind.Unspecified + }; + } + + internal void AddTracesCore(AddContext context, OtlpApplication application, RepeatedField scopeSpans) + { + _tracesLock.EnterWriteLock(); + + try + { + foreach (var scopeSpan in scopeSpans) + { + OtlpTraceScope? traceScope; + try + { + if (!_traceScopes.TryGetValue(scopeSpan.Scope.Name, out traceScope)) + { + traceScope = new OtlpTraceScope(scopeSpan.Scope); + _traceScopes.Add(scopeSpan.Scope.Name, traceScope); + } + } + catch (Exception ex) + { + context.FailureCount += scopeSpan.Spans.Count; + _logger.LogInformation(ex, "Error adding scope."); + continue; + } + + OtlpTrace? lastTrace = null; + + foreach (var span in scopeSpan.Spans) + { + try + { + OtlpTrace? trace; + bool newTrace = false; + + // Fast path to check if the span is in the same trace as the last span. + if (lastTrace != null && span.TraceId.Span.SequenceEqual(lastTrace.Key.Span)) + { + trace = lastTrace; + } + else if (!_traces.TryGetValue(span.TraceId.Memory, out trace)) + { + trace = new OtlpTrace(span.TraceId.Memory, traceScope); + newTrace = true; + } + + var newSpan = CreateSpan(application, span, trace); + trace.AddSpan(newSpan); + + // Traces are sorted by the start time of the first span. + // We need to ensure traces are in the correct order if we're: + // 1. Adding a new trace. + // 2. The first span of the trace has changed. + if (newTrace) + { + var added = false; + var position = _traces.Count; + for (var i = _traces.Count - 1; i >= 0; i--) + { + var currentTrace = _traces[i]; + if (trace.FirstSpan.StartTime > currentTrace.FirstSpan.StartTime) + { + _traces.Insert(i + 1, trace); + added = true; + break; + } + } + if (!added) + { + _traces.Insert(0, trace); + } + } + else + { + if (trace.FirstSpan == newSpan) + { + var moved = false; + var index = _traces.IndexOf(trace); + + for (var i = index - 1; i >= 0; i--) + { + var currentTrace = _traces[i]; + if (trace.FirstSpan.StartTime > currentTrace.FirstSpan.StartTime) + { + var insertPosition = i + 1; + if (index != insertPosition) + { + _traces.RemoveAt(index); + _traces.Insert(insertPosition, trace); + } + moved = true; + break; + } + } + if (!moved) + { + if (index != 0) + { + _traces.RemoveAt(index); + _traces.Insert(0, trace); + } + } + } + } + + lastTrace = trace; + } + catch (Exception ex) + { + context.FailureCount++; + _logger.LogInformation(ex, "Error adding span."); + } + } + + } + } + finally + { + _tracesLock.ExitWriteLock(); + } + } + + private static OtlpSpan CreateSpan(OtlpApplication application, Span span, OtlpTrace trace) + { + var id = span.SpanId?.ToHexString(); + if (id is null) + { + throw new ArgumentException("Span has no SpanId"); + } + + var events = new List(); + foreach (var e in span.Events) + { + events.Add(new OtlpSpanEvent() + { + Name = e.Name, + Time = OtlpHelpers.UnixNanoSecondsToDateTime(e.TimeUnixNano), + Attributes = e.Attributes.ToKeyValuePairs() + }); + } + + var newSpan = new OtlpSpan(application, trace) + { + SpanId = id, + ParentSpanId = span.ParentSpanId?.ToHexString(), + Name = span.Name, + Kind = ConvertSpanKind(span.Kind), + StartTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano), + EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), + Status = ConvertStatus(span.Status), + StatusMessage = span.Status?.Message, + Attributes = span.Attributes.ToKeyValuePairs(), + State = span.TraceState, + Events = events + }; + return newSpan; } } diff --git a/src/Aspire.Dashboard/wwwroot/css/app.css b/src/Aspire.Dashboard/wwwroot/css/app.css index 0e3888980d6..ebfbfde31c0 100644 --- a/src/Aspire.Dashboard/wwwroot/css/app.css +++ b/src/Aspire.Dashboard/wwwroot/css/app.css @@ -1,3 +1,7 @@ +body { + color: var(--neutral-foreground-rest); +} + fluent-toolbar fluent-switch, fluent-toolbar p { margin-inline-end: 15px; @@ -6,3 +10,145 @@ fluent-toolbar p { fluent-toolbar[orientation=horizontal] { width: 100%; } + + +/* Hide any web components that haven't been */ +:not(:defined), +.before-upgrade { + visibility: hidden; + +.trace-view-grid { + width: 100%; +} + .trace-view-grid .selected-span { + background-color: #FEFBDE; + } + .trace-view-grid .selected-span:hover { + background-color: #F9F6DB !important; + } + .trace-view-grid fluent-data-grid-row[row-type="default"]:hover .span-bar-label-detail { + display: inline !important; + } + .trace-view-grid fluent-data-grid-row[row-type="default"]:hover .span-bar-label { + color: black !important; + } + .trace-view-grid fluent-data-grid-row[row-type="default"]:hover { + background-color: #F5F5F5; + } + .trace-view-grid fluent-data-grid-row[row-type="header"] { + background: var(--fill-color); + border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest); + } + .trace-view-grid fluent-data-grid-row { + padding: 0; + border: 0; + align-items: center; + } + .trace-view-grid fluent-data-grid-cell { + padding: 0; + border: 0; + border-radius: 0; + vertical-align: middle; + } + .trace-view-grid fluent-data-grid-cell[grid-column="2"] fluent-divider { + display: none; + } + +.ticks { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + align-items: center; +} + .ticks .tick { + width: 1px; + height: 30px; + background: #d8d8d8; + grid-row: 1; + } + .ticks .end-tick { + justify-self: end; + grid-row: 1; + } + +.tick-label { + margin-left: 0.25rem; + margin-right: 0.25rem; + white-space: nowrap; + grid-row: 1; +} + +.span-container { + grid-column: 1 / span 4; + grid-row: 1; + height: 100%; + display: grid; + z-index: 1; + align-items: center; +} + .span-container .span-bar { + height: 15px; + border-radius: 5px; + cursor: pointer; + grid-row: 1; + } + .span-container .span-bar-label { + font-size: 12px; + color: #aaa; + padding: 0 0.5em; + cursor: pointer; + height: min-content; + } + .span-container .span-bar-label-detail { + display: none; + } + .span-container .span-bar-label-right { + grid-row: 1; + grid-column: 3; + } + .span-container .span-bar-label-left { + grid-row: 1; + grid-column: 1; + justify-self: end; + } + +.uninstrumented-peer { + padding-left: 0.5rem; +} + +.uninstrumented-peer-icon { + vertical-align: text-bottom; +} + +.span-row-name { + color: rgb(136, 136, 136); + padding-left: 0.5rem; + font-size: 12px; +} + +.trace-tag-icon { + margin-right: 3px; +} + +:root { + --duration-color: rgb(215, 231, 234); +} + +.trace-view-toolbar-detail { + margin-right: 15px; +} + + +.datagrid-overflow-area { + /* + Height of the browser - static height for other content + TODO: Is there a better way to do this? + */ + height: calc(100vh - 225px); + width: 100%; + overflow: auto; +} + .datagrid-overflow-area fluent-data-grid { + width: 100%; + height: 100%; + } diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 0bf9811f2f1..7df20360665 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -1,3 +1,16 @@ + +// To avoid Flash of Unstyled Content, the body is hidden by default with +// the before-upgrade CSS class. Here we'll find the first web component +// and wait for it to be upgraded. When it is, we'll remove that class +// from the body. +const firstUndefinedElement = document.body.querySelector(":not(:defined)"); + +if (firstUndefinedElement) { + customElements.whenDefined(firstUndefinedElement.localName).then(() => { + document.body.classList.remove("before-upgrade"); + }); +} + window.scrollToEndInTextArea = function (classSelector) { let fluentTextAreas = document.querySelectorAll(classSelector); if (fluentTextAreas && fluentTextAreas.length > 0) { @@ -24,32 +37,20 @@ window.scollToLogsEnd = function () { return; } - container.onscroll = (event) => { + // The scroll event is used to detect when the user scrolls to view content. + container.addEventListener('scroll', () => { isScrolledToContent = !isScrolledToBottom(container); - }; + }, { passive: true }); - // This method scrolls to the bottom of the logs data grid. It is called by blazor when rendering updates. - // The MutationObserver is used to detect when the aria-rowcount attribute is updated, which indicates that - // the logs have been updated and populated into the grid. At this point, we can scroll to the bottom of the grid. - - // Options for the observer (which mutations to observe) - const config = { attributes: true, attributeFilter: ["aria-rowcount"] }; - - let observer = null; - const callback = (mutationList, observer) => { - // Only scroll to the bottom if the current position is at the bottom. - // Prevents scrolling to the bottom when the user has scrolled up to view previous logs. + // The ResizeObserver reports changes in the grid size. + // This ensures that the logs are scrolled to the bottom when there are new logs + // unless the user has scrolled to view content. + const observer = new ResizeObserver(function () { if (!isScrolledToContent) { container.scrollTop = container.scrollHeight; } - - // Disconnect the observer when the logs have been scrolled to the bottom. - // Blazor rendering updates will create an observer. - observer.disconnect(); - }; - - observer = new MutationObserver(callback); - observer.observe(grid, config); + }); + observer.observe(grid); }; function isScrolledToBottom(container) { @@ -60,7 +61,7 @@ function isScrolledToBottom(container) { } window.copyTextToClipboard = function (id, text, precopy, postcopy) { - let tooltipDiv = document.querySelector('fluent-tooltip[anchor=' + id + ']').children[0]; + let tooltipDiv = document.querySelector('fluent-tooltip[anchor="' + id + '"]').children[0]; navigator.clipboard.writeText(text) .then(() => { tooltipDiv.innerText = postcopy; diff --git a/src/Aspire.Hosting.Sdk/Aspire.Hosting.Sdk.csproj b/src/Aspire.Hosting.Sdk/Aspire.Hosting.Sdk.csproj index ec1ce904811..e2da2496c99 100644 --- a/src/Aspire.Hosting.Sdk/Aspire.Hosting.Sdk.csproj +++ b/src/Aspire.Hosting.Sdk/Aspire.Hosting.Sdk.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Aspire.Hosting.Sdk/SDK/BundledVersions.in.targets b/src/Aspire.Hosting.Sdk/SDK/BundledVersions.in.targets deleted file mode 100644 index 441e67a58ee..00000000000 --- a/src/Aspire.Hosting.Sdk/SDK/BundledVersions.in.targets +++ /dev/null @@ -1,24 +0,0 @@ - - - - @VERSION@ - - win-x86;win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 - - - - - - - - diff --git a/src/Aspire.Hosting.Sdk/SDK/Sdk.targets b/src/Aspire.Hosting.Sdk/SDK/Sdk.in.targets similarity index 67% rename from src/Aspire.Hosting.Sdk/SDK/Sdk.targets rename to src/Aspire.Hosting.Sdk/SDK/Sdk.in.targets index b9884086888..2c3ed8e16a7 100644 --- a/src/Aspire.Hosting.Sdk/SDK/Sdk.targets +++ b/src/Aspire.Hosting.Sdk/SDK/Sdk.in.targets @@ -1,11 +1,13 @@ + true + @VERSION@ - + @@ -32,29 +34,13 @@ - - - - - - - - - - + diff --git a/src/Aspire.Hosting/Dcp/DcpHostService.cs b/src/Aspire.Hosting/Dcp/DcpHostService.cs index abb615730bc..822ce5709c2 100644 --- a/src/Aspire.Hosting/Dcp/DcpHostService.cs +++ b/src/Aspire.Hosting/Dcp/DcpHostService.cs @@ -111,13 +111,12 @@ private async Task EnsureDcpHostRunningAsync(CancellationToken cancellationToken { AspireEventSource.Instance.DcpApiServerLaunchStop(); } - + } private ProcessSpec CreateDcpProcessSpec() { - DcpRuntimeAttribute dcpRuntimeInformation = DcpRuntimeAttribute.GetDcpRuntimeAttribute(); - string dcpExePath = dcpRuntimeInformation.DcpPath; + string dcpExePath = Locations.DcpCliPath; if (!File.Exists(dcpExePath)) { throw new FileNotFoundException("The Aspire application host is not installed. The application cannot be run without it.", dcpExePath); @@ -143,14 +142,14 @@ private ProcessSpec CreateDcpProcessSpec() } } - if (!string.IsNullOrEmpty(dcpRuntimeInformation.DcpExtensionsPath)) + if (!string.IsNullOrEmpty(Locations.DcpExtensionsPath)) { - dcpProcessSpec.EnvironmentVariables.Add("DCP_EXTENSIONS_PATH", dcpRuntimeInformation.DcpExtensionsPath); + dcpProcessSpec.EnvironmentVariables.Add("DCP_EXTENSIONS_PATH", Locations.DcpExtensionsPath); } - if (!string.IsNullOrEmpty(dcpRuntimeInformation.DcpBinPath)) + if (!string.IsNullOrEmpty(Locations.DcpBinPath)) { - dcpProcessSpec.EnvironmentVariables.Add("DCP_BIN_PATH", dcpRuntimeInformation.DcpBinPath); + dcpProcessSpec.EnvironmentVariables.Add("DCP_BIN_PATH", Locations.DcpBinPath); } // Set an environment variable to contain session info that should be deleted when DCP is done diff --git a/src/Aspire.Hosting/Dcp/DcpRuntimeAttribute.cs b/src/Aspire.Hosting/Dcp/DcpRuntimeAttribute.cs deleted file mode 100644 index 9e8171582b9..00000000000 --- a/src/Aspire.Hosting/Dcp/DcpRuntimeAttribute.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Reflection; - -namespace Aspire.Hosting.Dcp; - -[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] -public class DcpRuntimeAttribute : Attribute -{ - public readonly string DcpPath; - public string? DcpExtensionsPath; - public string? DcpBinPath; - - public DcpRuntimeAttribute(string dcpPath) - { - DcpPath = dcpPath; - } - - public static DcpRuntimeAttribute GetDcpRuntimeAttribute() - { - Assembly? assembly = Assembly.GetEntryAssembly(); - var attribute = assembly?.GetCustomAttribute(); - if (attribute is null) - { - return new DcpRuntimeAttribute(Locations.DcpCliPath); - } - - return attribute; - } -} diff --git a/src/Aspire.Hosting/Dcp/Locations.cs b/src/Aspire.Hosting/Dcp/Locations.cs index 071957d95ea..1ff0e70543f 100644 --- a/src/Aspire.Hosting/Dcp/Locations.cs +++ b/src/Aspire.Hosting/Dcp/Locations.cs @@ -2,12 +2,52 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Reflection; using System.Runtime.InteropServices; namespace Aspire.Hosting.Dcp; internal static class Locations { + private const string DcpCliPathMetadataKey = "dcpclipath"; + private const string DcpExtensionsPathMetadataKey = "dcpextensionspath"; + private const string DcpBinPathMetadataKey = "dcpbinpath"; + + private static readonly Lazy?> s_assemblyMetadata = new Lazy?>(() => + { + Assembly? assembly = Assembly.GetEntryAssembly(); + return assembly?.GetCustomAttributes(); + }); + + private static readonly Lazy s_dcpCliPath = new Lazy(() => + { + string? dcpCliPath = s_assemblyMetadata.Value?.FirstOrDefault(m => string.Equals(m.Key, DcpCliPathMetadataKey, StringComparison.OrdinalIgnoreCase))?.Value; + + if (dcpCliPath != null) + { + return dcpCliPath; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(DcpDir, "dcp.exe"); + } + else + { + return Path.Combine(DcpDir, "dcp"); + } + }); + + private static readonly Lazy s_dcpExtensionsPath = new Lazy(() => + { + return s_assemblyMetadata.Value?.FirstOrDefault(m => string.Equals(m.Key, DcpExtensionsPathMetadataKey, StringComparison.OrdinalIgnoreCase))?.Value; + }); + + private static readonly Lazy s_dcpBinPath = new Lazy(() => + { + return s_assemblyMetadata.Value?.FirstOrDefault(m => string.Equals(m.Key, DcpBinPathMetadataKey, StringComparison.OrdinalIgnoreCase))?.Value; + }); + public static string DcpDir { get @@ -31,18 +71,9 @@ public static string DcpTempDir public static string DcpLogSocket => Path.Combine(DcpSessionDir, "output.sock"); - public static string DcpCliPath - { - get - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return Path.Combine(DcpDir, "dcp.exe"); - } - else - { - return Path.Combine(DcpDir, "dcp"); - } - } - } + public static string DcpCliPath => s_dcpCliPath.Value; + + public static string? DcpExtensionsPath => s_dcpExtensionsPath.Value; + + public static string? DcpBinPath => s_dcpBinPath.Value; } diff --git a/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs b/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs index 913e10df7b1..01591dc6673 100644 --- a/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs +++ b/src/Aspire.Hosting/Redis/RedisContainerBuilderExtensions.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Redis; public static class RedisContainerBuilderExtensions { - private const string RedisConnectionStringEnvironmentName = "Aspire__StackExchange__Redis__ConnectionString"; + private const string ConnectionStringEnvironmentName = "ConnectionStrings__"; public static IDistributedApplicationComponentBuilder AddRedisContainer(this IDistributedApplicationBuilder builder, string name, int? port = null) { @@ -20,9 +20,19 @@ public static IDistributedApplicationComponentBuilder A return componentBuilder; } - public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder projectBuilder, IDistributedApplicationComponentBuilder redisBuilder) + public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder projectBuilder, IDistributedApplicationComponentBuilder redisBuilder, string? connectionName = null) { - return projectBuilder.WithEnvironment(RedisConnectionStringEnvironmentName, () => + if (string.IsNullOrEmpty(connectionName)) + { + DistributedApplicationComponentExtensions.TryGetName(redisBuilder.Component, out connectionName); + + if (connectionName is null) + { + throw new DistributedApplicationException("Redis connection name could not be determined. Please provide one."); + } + } + + return projectBuilder.WithEnvironment(ConnectionStringEnvironmentName + connectionName, () => { if (!redisBuilder.Component.TryGetAnnotationsOfType(out var allocatedEndpoints)) { @@ -35,8 +45,8 @@ public static IDistributedApplicationComponentBuilder WithRedi }); } - public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder projectBuilder, string connectionString) + public static IDistributedApplicationComponentBuilder WithRedis(this IDistributedApplicationComponentBuilder projectBuilder, string connectionName, string connectionString) { - return projectBuilder.WithEnvironment(RedisConnectionStringEnvironmentName, connectionString); + return projectBuilder.WithEnvironment(ConnectionStringEnvironmentName + connectionName, connectionString); } } diff --git a/src/Aspire.Hosting/build/Aspire.Hosting.targets b/src/Aspire.Hosting/build/Aspire.Hosting.targets index 3ce5eb1b53c..87222d32e01 100644 --- a/src/Aspire.Hosting/build/Aspire.Hosting.targets +++ b/src/Aspire.Hosting/build/Aspire.Hosting.targets @@ -6,7 +6,7 @@ $(MSBuildProjectDirectory) - + @@ -43,7 +43,7 @@ public class ]]>%(ReferencePathWithRefAssemblies.ServiceNameOverride) - + @@ -61,4 +61,30 @@ public class ]]>%(ReferencePathWithRefAssemblies.ServiceNameOverride) + + + + + $([MSBuild]::NormalizePath($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.dcp')) + $([MSBuild]::NormalizePath($(DcpDir), 'ext')) + $([MSBuild]::NormalizePath($(DcpExtensionsPath), 'bin')) + $([MSBuild]::NormalizePath($(DcpDir), 'dcp')) + $(DcpCliPath).exe + + + + + <_Parameter1>dcpclipath + <_Parameter2>$(DcpCliPath) + + + <_Parameter1>dcpextensionpaths + <_Parameter2>$(DcpExtensionsPath) + + + <_Parameter1>dcpbinpath + <_Parameter2>$(DcpBinPath) + + + diff --git a/src/Aspire.ProjectTemplates/templates/aspire-empty/.template.config/template.json b/src/Aspire.ProjectTemplates/templates/aspire-empty/.template.config/template.json index 3f80e860b92..16c0e418a52 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-empty/.template.config/template.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-empty/.template.config/template.json @@ -9,7 +9,7 @@ "name": "Aspire Application", "defaultName": "AspireApp", "description": "A project template for creating an empty Aspire app.", - "shortName": "Aspire", + "shortName": "aspire", "sourceName": "AspireApplication1", "preferNameDirectory": true, "tags": { diff --git a/src/Aspire.ProjectTemplates/templates/aspire-starter/.template.config/template.json b/src/Aspire.ProjectTemplates/templates/aspire-starter/.template.config/template.json index 5a6669188a8..3228e2b0e38 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-starter/.template.config/template.json +++ b/src/Aspire.ProjectTemplates/templates/aspire-starter/.template.config/template.json @@ -14,7 +14,7 @@ "name": "Aspire Starter Application", "defaultName": "AspireApp", "description": "A project template for creating an Aspire app with a Blazor web frontend and web API backend service, optionally using Redis for caching.", - "shortName": "Aspire-starter", + "shortName": "aspire-starter", "sourceName": "AspireStarterApplication1", "preferNameDirectory": false, "tags": { diff --git a/src/Components/Aspire.StackExchange.Redis.DistributedCaching/AspireRedisDistributedCacheExtensions.cs b/src/Components/Aspire.StackExchange.Redis.DistributedCaching/AspireRedisDistributedCacheExtensions.cs index f95e0d392cb..41e24206dfa 100644 --- a/src/Components/Aspire.StackExchange.Redis.DistributedCaching/AspireRedisDistributedCacheExtensions.cs +++ b/src/Components/Aspire.StackExchange.Redis.DistributedCaching/AspireRedisDistributedCacheExtensions.cs @@ -15,6 +15,7 @@ public static class AspireRedisDistributedCacheExtensions /// Adds Redis distributed caching services, , in the services provided by the . /// /// The to read config from and add services to. + /// An optional name used to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// @@ -23,9 +24,9 @@ public static class AspireRedisDistributedCacheExtensions /// Also registers as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// - public static void AddRedisDistributedCache(this IHostApplicationBuilder builder, Action? configureSettings = null, Action? configureOptions = null) + public static void AddRedisDistributedCache(this IHostApplicationBuilder builder, string? connectionName = null, Action? configureSettings = null, Action? configureOptions = null) { - builder.AddRedis(configureSettings, configureOptions); + builder.AddRedis(connectionName, configureSettings, configureOptions); builder.AddRedisDistributedCacheCore((RedisCacheOptions options, IServiceProvider sp) => { @@ -37,7 +38,7 @@ public static void AddRedisDistributedCache(this IHostApplicationBuilder builder /// Adds Redis distributed caching services, , in the services provided by the . /// /// The to read config from and add services to. - /// The of the service. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// @@ -46,9 +47,9 @@ public static void AddRedisDistributedCache(this IHostApplicationBuilder builder /// Also registers as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// - public static void AddRedisDistributedCache(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) + public static void AddKeyedRedisDistributedCache(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) { - builder.AddRedis(name, configureSettings, configureOptions); + builder.AddKeyedRedis(name, configureSettings, configureOptions); builder.AddRedisDistributedCacheCore((RedisCacheOptions options, IServiceProvider sp) => { diff --git a/src/Components/Aspire.StackExchange.Redis.DistributedCaching/README.md b/src/Components/Aspire.StackExchange.Redis.DistributedCaching/README.md index e0aaf1c808e..f8145b9e09f 100644 --- a/src/Components/Aspire.StackExchange.Redis.DistributedCaching/README.md +++ b/src/Components/Aspire.StackExchange.Redis.DistributedCaching/README.md @@ -39,6 +39,26 @@ public ProductsController(IDistributedCache cache) The Aspire StackExchange Redis Distributed Cache component provides multiple options to configure the Redis connection based on the requirements and conventions of your project. Note that at least one host name is required to connect. +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddRedisDistributedCache()`: + +```cs +builder.AddRedisDistributedCache("myRedisConnectionName"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myRedisConnectionName": "localhost:6379" + } +} +``` + +See the [Basic Configuration Settings](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#basic-configuration-strings) of the StackExchange.Redis docs for more information on how to format this connection string. + ### Use configuration providers The Redis Distributed Cache component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `StackExchangeRedisSettings` and `ConfigurationOptions` from configuration by using the `Aspire:StackExchange:Redis` key. Example `appsettings.json` that configures some of the options: @@ -62,21 +82,39 @@ The Redis Distributed Cache component supports [Microsoft.Extensions.Configurati ### Use inline delegates -You can also pass the `Action` delegate to set up some or all the options inline, for example to disable tracing: +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to use a connection string from code: + +```cs +builder.AddRedisDistributedCache(configureSettings: settings => settings.ConnectionString = "localhost:6379"); +``` + +You can also setup the [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) using the `Action configureOptions` delegate parameter of the `AddRedisDistributedCache` method. For example to set the connection timeout: ```cs - builder.AddRedisDistributedCache(settings => settings.Tracing = false); +builder.AddRedisDistributedCache(configureOptions: options => options.ConnectTimeout = 3000); +``` + +## DevHost Extensions + +In your DevHost project, register a Redis container and consume the connection using the following methods: + +```cs +var redis = builder.AddRedisContainer("cache"); + +var myService = builder.AddProject() + .WithRedis(redis); ``` -You can also setup the [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) using the `Action` delegate, the second parameter of the `AddRedisDistributedCache` method. For example to set the connection timeout: +`.WithRedis` configures a connection in the `MyService` project named `cache`. In the `Program.cs` file of `MyService`, the redis connection can be consumed using: ```cs - builder.AddRedisDistributedCache(null, options => options.ConnectTimeout = 3000); +builder.AddRedisDistributedCache("cache"); ``` ## Additional documentation -https://github.com/dotnet/astra/tree/main/src/Components/README.md +* https://learn.microsoft.com/aspnet/core/performance/caching/distributed +* https://github.com/dotnet/astra/tree/main/src/Components/README.md ## Feedback & Contributing diff --git a/src/Components/Aspire.StackExchange.Redis.OutputCaching/AspireRedisOutputCacheExtensions.cs b/src/Components/Aspire.StackExchange.Redis.OutputCaching/AspireRedisOutputCacheExtensions.cs index e04c3ce0efe..18a056fcd32 100644 --- a/src/Components/Aspire.StackExchange.Redis.OutputCaching/AspireRedisOutputCacheExtensions.cs +++ b/src/Components/Aspire.StackExchange.Redis.OutputCaching/AspireRedisOutputCacheExtensions.cs @@ -14,6 +14,7 @@ public static class AspireRedisOutputCacheExtensions /// Adds Redis output caching services in the services provided by the . /// /// The to read config from and add services to. + /// An optional name used to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// @@ -22,9 +23,9 @@ public static class AspireRedisOutputCacheExtensions /// Also registers as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// - public static void AddRedisOutputCache(this IHostApplicationBuilder builder, Action? configureSettings = null, Action? configureOptions = null) + public static void AddRedisOutputCache(this IHostApplicationBuilder builder, string? connectionName = null, Action? configureSettings = null, Action? configureOptions = null) { - builder.AddRedis(configureSettings, configureOptions); + builder.AddRedis(connectionName, configureSettings, configureOptions); builder.AddRedisOutputCacheCore((RedisOutputCacheOptions options, IServiceProvider sp) => { @@ -36,7 +37,7 @@ public static void AddRedisOutputCache(this IHostApplicationBuilder builder, Act /// Adds Redis output caching services for the given in the services provided by the . /// /// The to read config from and add services to. - /// The of the service. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// @@ -45,9 +46,9 @@ public static void AddRedisOutputCache(this IHostApplicationBuilder builder, Act /// Also registers as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// - public static void AddRedisOutputCache(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) + public static void AddKeyedRedisOutputCache(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) { - builder.AddRedis(name, configureSettings, configureOptions); + builder.AddKeyedRedis(name, configureSettings, configureOptions); builder.AddRedisOutputCacheCore((RedisOutputCacheOptions options, IServiceProvider sp) => { diff --git a/src/Components/Aspire.StackExchange.Redis.OutputCaching/README.md b/src/Components/Aspire.StackExchange.Redis.OutputCaching/README.md index e87b97ac3ad..94a3ae0370d 100644 --- a/src/Components/Aspire.StackExchange.Redis.OutputCaching/README.md +++ b/src/Components/Aspire.StackExchange.Redis.OutputCaching/README.md @@ -1,6 +1,6 @@ # Aspire.StackExchange.Redis.OutputCaching library -Registers an [IConnectionMultiplexer](https://stackexchange.github.io/StackExchange.Redis/Basics) in the DI container for connecting to a [Redis](https://redis.io/) server. Uses that IConnectionMultiplexer in [ASP.NET Core Output Caching](https://learn.microsoft.com/aspnet/core/performance/caching/output). Enables corresponding health check, logging, and telemetry. +Registers an [ASP.NET Core Output Caching](https://learn.microsoft.com/aspnet/core/performance/caching/output) provider backed by a [Redis](https://redis.io/) server. Enables corresponding health check, logging, and telemetry. ## Getting started @@ -18,79 +18,108 @@ dotnet add package Aspire.StackExchange.Redis.OutputCaching ## Usage Example -Call `AddRedisOutputCache` extension method to add the Redis distributed caching services and an `IConnectionMultiplexer` singleton with the desired configurations exposed with `StackExchangeRedisSettings`. The library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `StackExchangeRedisSettings` from configuration by using `Aspire:StackExchange:Redis` key. Note that at least one host name is required to connect. Example `appsettings.json` that configures some of the options: +In the `Program.cs` file of your project, call the `AddRedisOutputCache` extension to register the Redis output cache provider in the dependency injection container. + +```cs +builder.AddRedisOutputCache(); +``` + +After the `WebApplication` has been built, add the middleware to the request processing pipeline by calling [UseOutputCache](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.outputcacheapplicationbuilderextensions.useoutputcache). + +```cs +app.UseOutputCache(); +``` + +For minimal API apps, configure an endpoint to do caching by calling [CacheOutput](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dependencyinjection.outputcacheconventionbuilderextensions.cacheoutput), or by applying the [`[OutputCache]`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.outputcaching.outputcacheattribute) attribute, as shown in the following examples: + +```cs +app.MapGet("/cached", Gravatar.WriteGravatar).CacheOutput(); +app.MapGet("/attribute", [OutputCache] (context) => + Gravatar.WriteGravatar(context)); +``` + +For apps with controllers, apply the `[OutputCache]` attribute to the action method. For Razor Pages apps, apply the attribute to the Razor page class. + +## Configuration + +The Aspire StackExchange Redis OutputCache component provides multiple options to configure the Redis connection based on the requirements and conventions of your project. Note that at least one host name is required to connect. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddRedisOutputCache()`: + +```cs +builder.AddRedisOutputCache("myRedisConnectionName"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: ```json { - "Aspire": { - "StackExchange": { - "Redis": { - "ConnectionString": "localhost:6379", - "ConfigurationOptions": { - "ConnectTimeout": 5000, - "ConnectRetry": 2 - } - } - } + "ConnectionStrings": { + "myRedisConnectionName": "localhost:6379" } } ``` -If you have setup your configurations in the `Aspire.StackExchange.Redis` section you can just call the method without passing any parameter. -```cs - builder.AddRedisOutputCache(); -``` +See the [Basic Configuration Settings](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#basic-configuration-strings) of the StackExchange.Redis docs for more information on how to format this connection string. + +### Use configuration providers -If you want to add more than one [IConnectionMultiplexer](https://stackexchange.github.io/StackExchange.Redis/Basics) you could use a named instances. The json configuration would look like: +The Redis OutputCache component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `StackExchangeRedisSettings` and `ConfigurationOptions` from configuration by using the `Aspire:StackExchange:Redis` key. Example `appsettings.json` that configures some of the options: ```json { "Aspire": { "StackExchange": { "Redis": { - "INSTANCE_NAME": { - "ConnectionString": "localhost:6379", - "ConfigurationOptions": { - "ConnectTimeout": 5000, - "ConnectRetry": 2 - } - } + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + }, + "Tracing": false } } } } ``` -To load the named configuration section from the json config call the `AddRedisOutputCache` method by passing the `INSTANCE_NAME`. +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to use a connection string from code: ```cs - builder.AddRedisOutputCache("INSTANCE_NAME"); +builder.AddRedisOutputCache(configureSettings: settings => settings.ConnectionString = "localhost:6379"); ``` -Also you can pass the `Action` delegate to set up some or all the options inline, for example to set the `Tracing`: +You can also setup the [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) using the `Action configureOptions` delegate parameter of the `AddRedisOutputCache` method. For example to set the connection timeout: ```cs - builder.AddRedisOutputCache(settings => settings.Tracing = false); +builder.AddRedisOutputCache(configureOptions: options => options.ConnectTimeout = 3000); ``` -Here are the configurable options with corresponding default values: +## DevHost Extensions + +In your DevHost project, register a Redis container and consume the connection using the following methods: ```cs -public sealed class StackExchangeRedisSettings -{ - // A boolean value that indicates whether the Redis health check is enabled or not. - public bool HealthChecks { get; set; } = true; +var redis = builder.AddRedisContainer("cache"); - // A boolean value that indicates whether the OpenTelemetry tracing is enabled or not. - public bool Tracing { get; set; } = true; -} +var myService = builder.AddProject() + .WithRedis(redis); ``` -Check [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) for more info about client config options. +`.WithRedis` configures a connection in the `MyService` project named `cache`. In the `Program.cs` file of `MyService`, the redis connection can be consumed using: + +```cs +builder.AddRedisOutputCache("cache"); +``` ## Additional documentation -https://github.com/dotnet/astra/tree/main/src/Components/README.md +* https://learn.microsoft.com/aspnet/core/performance/caching/output +* https://github.com/dotnet/astra/tree/main/src/Components/README.md ## Feedback & Contributing diff --git a/src/Components/Aspire.StackExchange.Redis/AspireRedisExtensions.cs b/src/Components/Aspire.StackExchange.Redis/AspireRedisExtensions.cs index 6d4ac547732..9ee2ca9cd44 100644 --- a/src/Components/Aspire.StackExchange.Redis/AspireRedisExtensions.cs +++ b/src/Components/Aspire.StackExchange.Redis/AspireRedisExtensions.cs @@ -18,33 +18,34 @@ public static class AspireRedisExtensions private const string DefaultConfigSectionName = "Aspire:StackExchange:Redis"; /// - /// Registers as a singleton in the services provided by the . + /// Registers as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// /// The to read config from and add services to. + /// An optional name used to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// Reads the configuration from "Aspire.StackExchange.Redis" section. - public static void AddRedis(this IHostApplicationBuilder builder, Action? configureSettings = null, Action? configureOptions = null) - => AddRedis(builder, DefaultConfigSectionName, configureSettings, configureOptions, name: null); + public static void AddRedis(this IHostApplicationBuilder builder, string? connectionName = null, Action? configureSettings = null, Action? configureOptions = null) + => AddRedis(builder, DefaultConfigSectionName, configureSettings, configureOptions, connectionName, serviceKey: null); /// - /// Registers as a singleton for the given in the services provided by the . + /// Registers as a keyed singleton for the given in the services provided by the . /// Enables retries, corresponding health check, logging, and telemetry. /// /// The to read config from and add services to. - /// The of the service. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. /// An optional method that can be used for customizing the . It's invoked after the options are read from the configuration. /// Reads the configuration from "Aspire.StackExchange.Redis:{name}" section. - public static void AddRedis(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) + public static void AddKeyedRedis(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureOptions = null) { ArgumentException.ThrowIfNullOrEmpty(name); - AddRedis(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, configureOptions, name); + AddRedis(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, configureOptions, connectionName: name, serviceKey: name); } - private static void AddRedis(IHostApplicationBuilder builder, string configurationSectionName, Action? configureSettings, Action? configureOptions, string? name) + private static void AddRedis(IHostApplicationBuilder builder, string configurationSectionName, Action? configureSettings, Action? configureOptions, string? connectionName, object? serviceKey) { ArgumentNullException.ThrowIfNull(builder); @@ -56,10 +57,18 @@ private static void AddRedis(IHostApplicationBuilder builder, string configurati configureSettings?.Invoke(settings); // see comments on ConfigurationOptionsFactory for why a factory is used here - builder.Services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory), typeof(ConfigurationOptionsFactory))); - + builder.Services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory), + sp => new ConfigurationOptionsFactory( + settings, + connectionName, + sp.GetRequiredService(), + sp.GetServices>(), + sp.GetServices>(), + sp.GetServices>()))); + + string? optionsName = serviceKey is null ? null : connectionName; builder.Services.Configure( - name ?? Options.Options.DefaultName, + optionsName ?? Options.Options.DefaultName, configurationOptions => { BindToConfiguration(configurationOptions, configSection); @@ -67,15 +76,15 @@ private static void AddRedis(IHostApplicationBuilder builder, string configurati configureOptions?.Invoke(configurationOptions); }); - if (name is null) + if (serviceKey is null) { builder.Services.AddSingleton( - sp => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, configurationSectionName), CreateLogger(sp))); + sp => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, configurationSectionName, optionsName), CreateLogger(sp))); } else { - builder.Services.AddKeyedSingleton(name, - (sp, key) => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, configurationSectionName, name), CreateLogger(sp))); + builder.Services.AddKeyedSingleton(serviceKey, + (sp, key) => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, configurationSectionName, optionsName), CreateLogger(sp))); } if (settings.Tracing) @@ -95,8 +104,8 @@ private static void AddRedis(IHostApplicationBuilder builder, string configurati // The connection factory tries to open the connection and throws when it fails. // That is why we don't invoke it here, but capture the state (in a closure) // and let the health check invoke it and handle the exception (if any). - connectionMultiplexerFactory: sp => name is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(name), - name: string.IsNullOrEmpty(name) ? "StackExchange.Redis" : $"StackExchange.Redis_{name}"); + connectionMultiplexerFactory: sp => serviceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(serviceKey), + name: serviceKey is null ? "StackExchange.Redis" : $"StackExchange.Redis_{connectionName}"); } static TextWriter? CreateLogger(IServiceProvider serviceProvider) @@ -105,11 +114,11 @@ private static void AddRedis(IHostApplicationBuilder builder, string configurati : null; } - private static ConfigurationOptions GetConfigurationOptions(IServiceProvider serviceProvider, string configurationSectionName, string? name = null) + private static ConfigurationOptions GetConfigurationOptions(IServiceProvider serviceProvider, string configurationSectionName, string? optionsName) { - var configurationOptions = name is null ? + var configurationOptions = optionsName is null ? serviceProvider.GetRequiredService>().Value : - serviceProvider.GetRequiredService>().Get(name); + serviceProvider.GetRequiredService>().Get(optionsName); if (configurationOptions is null || configurationOptions.EndPoints.Count == 0) { @@ -147,30 +156,24 @@ private sealed class LoggingTextWriter(ILogger logger) : TextWriter /// private sealed class ConfigurationOptionsFactory : OptionsFactory { + private readonly StackExchangeRedisSettings _settings; + private readonly string? _connectionStringName; private readonly IConfiguration _configuration; - public ConfigurationOptionsFactory(IConfiguration configuration, IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> validations) + public ConfigurationOptionsFactory(StackExchangeRedisSettings settings, string? connectionStringName, IConfiguration configuration, IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> validations) : base(setups, postConfigures, validations) { + _settings = settings; + _connectionStringName = connectionStringName; _configuration = configuration; } protected override ConfigurationOptions CreateInstance(string name) { - var baseConfigSectionName = string.IsNullOrEmpty(name) ? DefaultConfigSectionName : $"{DefaultConfigSectionName}:{name}"; - var connectionStringConfigName = $"{baseConfigSectionName}:ConnectionString"; - - var connectionString = _configuration[connectionStringConfigName]; - if (string.IsNullOrEmpty(connectionString)) + var connectionString = _settings.ConnectionString; + if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(_connectionStringName)) { - if (string.IsNullOrEmpty(name)) - { - connectionString = _configuration.GetConnectionString("Aspire.StackExchange.Redis"); - } - else - { - connectionString = _configuration.GetConnectionString(name); - } + connectionString = _configuration.GetConnectionString(_connectionStringName); } return connectionString is not null ? diff --git a/src/Components/Aspire.StackExchange.Redis/ConfigurationSchema.json b/src/Components/Aspire.StackExchange.Redis/ConfigurationSchema.json index b38f344e621..e1c8353edb4 100644 --- a/src/Components/Aspire.StackExchange.Redis/ConfigurationSchema.json +++ b/src/Components/Aspire.StackExchange.Redis/ConfigurationSchema.json @@ -123,7 +123,7 @@ }, "ConnectionString": { "type": "string", - "description": "A comma-delimited configuration string." + "description": "Gets or sets the comma-delimited configuration string used to connect to the Redis server." }, "HealthChecks": { "type": "boolean", diff --git a/src/Components/Aspire.StackExchange.Redis/README.md b/src/Components/Aspire.StackExchange.Redis/README.md index 7dbe5bb454d..ba77932f652 100644 --- a/src/Components/Aspire.StackExchange.Redis/README.md +++ b/src/Components/Aspire.StackExchange.Redis/README.md @@ -18,84 +18,105 @@ dotnet add package Aspire.StackExchange.Redis ## Usage Example -Call `AddRedis` extension method to add an `IConnectionMultiplexer` singleton with the desired configurations exposed with `StackExchangeRedisSettings`. The library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `StackExchangeRedisSettings` from configuration by using `Aspire:StackExchange:Redis` key. Note that at least one host name is required to connect. Example `appsettings.json` that configures some of the options: +In the `Program.cs` file of your project, call the `AddRedis` extension method to register an `IConnectionMultiplexer` for use via the dependency injection container. -```json +```cs +builder.AddRedis(); +``` + +You can then retrieve the `IConnectionMultiplexer` instance using dependency injection. For example, to retrieve the cache from a Web API controller: + +```cs +private readonly IConnectionMultiplexer _cache; + +public ProductsController(IConnectionMultiplexer cache) { - "Aspire": { - "StackExchange": { - "Redis": { - "ConnectionString": "localhost:6379", - "ConfigurationOptions": { - "ConnectTimeout": 5000, - "ConnectRetry": 2 - }, - "Tracing": false - } - } - } + _cache = cache; } ``` - - If you have setup your configurations in the `Aspire.StackExchange.Redis` section you can just call the method without passing any parameter. - + +See the [StackExchange.Redis documentation](https://stackexchange.github.io/StackExchange.Redis/Basics) for examples on using the `IConnectionMultiplexer`. + +## Configuration + +The Aspire StackExchange Redis component provides multiple options to configure the Redis connection based on the requirements and conventions of your project. Note that at least one host name is required to connect. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddRedis()`: + ```cs - builder.AddRedis(); +builder.AddRedis("myRedisConnectionName"); ``` -If you want to add more than one [IConnectionMultiplexer](https://stackexchange.github.io/StackExchange.Redis/Basics) you could use a named instances. The json configuration would look like: +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myRedisConnectionName": "localhost:6379" + } +} +``` + +See the [Basic Configuration Settings](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#basic-configuration-strings) of the StackExchange.Redis docs for more information on how to format this connection string. + +### Use configuration providers + +The Redis component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `StackExchangeRedisSettings` and `ConfigurationOptions` from configuration by using the `Aspire:StackExchange:Redis` key. Example `appsettings.json` that configures some of the options: ```json { "Aspire": { "StackExchange": { "Redis": { - "INSTANCE_NAME": { - "ConnectionString": "localhost:6379", - "ConfigurationOptions": { - "ConnectTimeout": 5000, - "ConnectRetry": 2 - }, - "Tracing": false - } + "ConnectionString": "localhost:6379", + "ConfigurationOptions": { + "ConnectTimeout": 3000, + "ConnectRetry": 2 + }, + "Tracing": false } } } } ``` -To load the named configuration section from the json config call the `AddRedis` method by passing the `INSTANCE_NAME`. +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to use a connection string from code: ```cs - builder.AddRedis("INSTANCE_NAME"); +builder.AddRedis(configureSettings: settings => settings.ConnectionString = "localhost:6379"); ``` -Also you can pass the `Action` delegate to set up some or all the options inline, for example to set the `Tracing`: +You can also setup the [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) using the `Action configureOptions` delegate parameter of the `AddRedis` method. For example to set the connection timeout: ```cs - builder.AddRedis(settings => settings.Tracing = false); +builder.AddRedis(configureOptions: options => options.ConnectTimeout = 3000); ``` -Here are the configurable options with corresponding default values: +## DevHost Extensions + +In your DevHost project, register a Redis container and consume the connection using the following methods: ```cs -public sealed class StackExchangeRedisSettings -{ - // A boolean value that indicates whether the Redis health check is enabled or not. - public bool HealthChecks { get; set; } = true; +var redis = builder.AddRedisContainer("cache"); - // A boolean value that indicates whether the OpenTelemetry tracing is enabled or not. - public bool Tracing { get; set; } = true; -} +var myService = builder.AddProject() + .WithRedis(redis); ``` -Check [ConfigurationOptions](https://stackexchange.github.io/StackExchange.Redis/Configuration.html#configuration-options) for more info about client config options. +`.WithRedis` configures a connection in the `MyService` project named `cache`. In the `Program.cs` file of `MyService`, the redis connection can be consumed using: -After adding a `IConnectionMultiplexer` to the builder you can get the `IConnectionMultiplexer` singleton instance using DI. +```cs +builder.AddRedis("cache"); +``` ## Additional documentation -https://github.com/dotnet/astra/tree/main/src/Components/README.md +* https://stackexchange.github.io/StackExchange.Redis/Basics +* https://github.com/dotnet/astra/tree/main/src/Components/README.md ## Feedback & Contributing diff --git a/src/Components/Aspire.StackExchange.Redis/StackExchangeRedisSettings.cs b/src/Components/Aspire.StackExchange.Redis/StackExchangeRedisSettings.cs index dac025ba216..cefdadd5277 100644 --- a/src/Components/Aspire.StackExchange.Redis/StackExchangeRedisSettings.cs +++ b/src/Components/Aspire.StackExchange.Redis/StackExchangeRedisSettings.cs @@ -5,6 +5,11 @@ namespace Aspire.StackExchange.Redis; public sealed class StackExchangeRedisSettings { + /// + /// Gets or sets the comma-delimited configuration string used to connect to the Redis server. + /// + public string? ConnectionString { get; set; } + /// /// Gets or sets a boolean value that indicates whether the Redis health check is enabled or not. /// Enabled by default. diff --git a/src/Microsoft.NET.Sdk.Aspire/WorkloadManifest.in.json b/src/Microsoft.NET.Sdk.Aspire/WorkloadManifest.in.json index 2b1a568b4b9..552d8912e7a 100644 --- a/src/Microsoft.NET.Sdk.Aspire/WorkloadManifest.in.json +++ b/src/Microsoft.NET.Sdk.Aspire/WorkloadManifest.in.json @@ -3,14 +3,13 @@ "workloads": { "aspire": { "description": ".NET Aspire SDK", - "extends": [ "aspire-host-runtime" ], "packs": [ "Aspire.Hosting.Sdk", "Aspire.ProjectTemplates", - "Microsoft.DeveloperControlPlane", - "Aspire.Hosting.Ref" + "Aspire.Hosting.Orchestration", + "Aspire.Hosting" ], - // both DCP and Aspire.Hosting.Runtime only support these hosts + // DCP only support these hosts "platforms": [ "win-x64", "win-arm64", @@ -19,17 +18,6 @@ "osx-x64", "osx-arm64" ] - }, - "aspire-host-runtime": { - "abstract": true, - "packs": [ - "Aspire.Hosting.Runtime.win-x64", - "Aspire.Hosting.Runtime.win-arm64", - "Aspire.Hosting.Runtime.linux-x64", - "Aspire.Hosting.Runtime.linux-arm64", - "Aspire.Hosting.Runtime.osx-x64", - "Aspire.Hosting.Runtime.osx-arm64" - ] } }, "packs": { @@ -41,81 +29,22 @@ "kind": "template", "version": "@VERSION@" }, - "Microsoft.DeveloperControlPlane": { + "Aspire.Hosting.Orchestration": { "kind": "sdk", - "version": "@DCP_VERSION@", - "alias-to": { - "win-x86": "Microsoft.DeveloperControlPlane.windows-386", - "win-x64": "Microsoft.DeveloperControlPlane.windows-amd64", - "win-arm64": "Microsoft.DeveloperControlPlane.windows-arm64", - "linux-x64": "Microsoft.DeveloperControlPlane.linux-amd64", - "linux-arm64": "Microsoft.DeveloperControlPlane.linux-arm64", - "osx-x64": "Microsoft.DeveloperControlPlane.darwin-amd64", - "osx-arm64": "Microsoft.DeveloperControlPlane.darwin-arm64" - } - }, - "Aspire.Hosting.Ref": { - "kind": "framework", - "version": "@VERSION@" - }, - /* - Here we are not using alias-to to map a single pack ID to host specific variants like normal. - Instead, we are making sure that each runtime pack is only installed on the matching host, - as we do not expect Aspire host projects to be built for a platform other than the host, - so it's pointless to install the runtime packs for other platforms. - - This means the build sees and treats these like any other RID-specific runtime packs. If for any - reason a RID-specific pack is needed other than the one installed by the workload, it will be - downloaded into the NuGet package directory at build time. - */ - "Aspire.Hosting.Runtime.win-x86": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "win-x64": "Aspire.Hosting.Runtime.win-x86" - } - }, - "Aspire.Hosting.Runtime.win-x64": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "win-x64": "Aspire.Hosting.Runtime.win-x64" - } - }, - "Aspire.Hosting.Runtime.win-arm64": { - "kind": "framework", "version": "@VERSION@", "alias-to": { - "win-arm64": "Aspire.Hosting.Runtime.win-arm64" + "win-x86": "Aspire.Hosting.Orchestration.win-x86", + "win-x64": "Aspire.Hosting.Orchestration.win-x64", + "win-arm64": "Aspire.Hosting.Orchestration.win-arm64", + "linux-x64": "Aspire.Hosting.Orchestration.linux-x64", + "linux-arm64": "Aspire.Hosting.Orchestration.linux-arm64", + "osx-x64": "Aspire.Hosting.Orchestration.osx-x64", + "osx-arm64": "Aspire.Hosting.Orchestration.osx-arm64" } }, - "Aspire.Hosting.Runtime.linux-x64": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "linux-x64": "Aspire.Hosting.Runtime.linux-x64" - } - }, - "Aspire.Hosting.Runtime.linux-arm64": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "linux-arm64": "Aspire.Hosting.Runtime.linux-arm64" - } - }, - "Aspire.Hosting.Runtime.osx-x64": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "osx-x64": "Aspire.Hosting.Runtime.osx-x64" - } - }, - "Aspire.Hosting.Runtime.osx-arm64": { - "kind": "framework", - "version": "@VERSION@", - "alias-to": { - "osx-arm64": "Aspire.Hosting.Runtime.osx-arm64" - } + "Aspire.Hosting": { + "kind": "library", + "version": "@VERSION@" } } } diff --git a/src/WorkloadFrameworks/Aspire.Hosting.Ref.sfxproj b/src/WorkloadFrameworks/Aspire.Hosting.Ref.sfxproj deleted file mode 100644 index 9d9971f5deb..00000000000 --- a/src/WorkloadFrameworks/Aspire.Hosting.Ref.sfxproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - $(NetCurrent) - any - Aspire.Hosting - TargetingPack - Aspire.Hosting.Runtime.sfxproj - - - - - - - - - - - - - - diff --git a/src/WorkloadFrameworks/Aspire.Hosting.Runtime.sfxproj b/src/WorkloadFrameworks/Aspire.Hosting.Runtime.sfxproj deleted file mode 100644 index 4c53a51b6d6..00000000000 --- a/src/WorkloadFrameworks/Aspire.Hosting.Runtime.sfxproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - $(NetCurrent) - win-x86;win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 - Aspire.Hosting - RuntimePack - false - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Aspire.Dashboard.Tests/DurationFormatterTests.cs b/tests/Aspire.Dashboard.Tests/DurationFormatterTests.cs new file mode 100644 index 00000000000..6bcbdee925d --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/DurationFormatterTests.cs @@ -0,0 +1,72 @@ +// 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.Otlp.Model; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class DurationFormatterTests +{ + [Fact] + public void KeepsMicrosecondsTheSame() + { + Assert.Equal("1μs", DurationFormatter.FormatDuration(TimeSpan.FromTicks(1 * TimeSpan.TicksPerMicrosecond))); + } + + [Fact] + public void DisplaysMaximumOf2UnitsAndRoundsLastOne() + { + var input = 10 * TimeSpan.TicksPerDay + 13 * TimeSpan.TicksPerHour + 30 * TimeSpan.TicksPerMinute; + Assert.Equal("10d 14h", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void SkipsUnitsThatAreEmpty() + { + var input = 2 * TimeSpan.TicksPerDay + 5 * TimeSpan.TicksPerMinute; + Assert.Equal("2d", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void DisplaysMillisecondsInDecimals() + { + var input = 2 * TimeSpan.TicksPerMillisecond + 357 * TimeSpan.TicksPerMicrosecond; + Assert.Equal("2.36ms", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void DisplaysSecondsInDecimals() + { + var input = 2 * TimeSpan.TicksPerSecond + 357 * TimeSpan.TicksPerMillisecond; + Assert.Equal("2.36s", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void DisplaysMinutesInSplitUnits() + { + var input = 2 * TimeSpan.TicksPerMinute + 30 * TimeSpan.TicksPerSecond + 555 * TimeSpan.TicksPerMillisecond; + Assert.Equal("2m 31s", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void DisplaysHoursInSplitUnits() + { + var input = 2 * TimeSpan.TicksPerHour + 30 * TimeSpan.TicksPerMinute + 30 * TimeSpan.TicksPerSecond; + Assert.Equal("2h 31m", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } + + [Fact] + public void DisplaysTimesLessThanMicroseconds() + { + var input = (double)TimeSpan.TicksPerMicrosecond / 10; + Assert.Equal("0.1μs", DurationFormatter.FormatDuration(TimeSpan.FromTicks((long)input))); + } + + [Fact] + public void DisplaysTimesOf0() + { + var input = 0; + Assert.Equal("0μs", DurationFormatter.FormatDuration(TimeSpan.FromTicks(input))); + } +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests.cs index b33b1a916c3..6cf350433f7 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests.cs @@ -1,6 +1,7 @@ // 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; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf; @@ -10,6 +11,7 @@ using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Resource.V1; +using OpenTelemetry.Proto.Trace.V1; using Xunit; namespace Aspire.Dashboard.Tests; @@ -96,7 +98,7 @@ private void GetLogs_UnknownApplication() }); // Assert - Assert.Null(logs); + Assert.Empty(logs.Items); } [Fact] @@ -109,7 +111,7 @@ public void GetLogPropertyKeys_UnknownApplication() var propertyKeys = repository.GetLogPropertyKeys("UnknownApplication"); // Assert - Assert.Null(propertyKeys); + Assert.Empty(propertyKeys); } [Fact] @@ -230,6 +232,267 @@ public void Unsubscribe() Assert.False(onNewApplicationsCalled, "Callback shouldn't have been called because subscription was disposed."); } + private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void AddTraces() + { + // Arrange + var repository = CreateRepository(); + + // Act + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1") + } + } + } + } + }); + + // Assert + Assert.Equal(0, addContext.FailureCount); + + var applications = repository.GetApplications(); + Assert.Collection(applications, + app => + { + Assert.Equal("TestService", app.ApplicationName); + Assert.Equal("TestId", app.InstanceId); + }); + + var traces = repository.GetTraces(new GetTracesContext + { + ApplicationServiceId = applications[0].InstanceId, + StartIndex = 0, + Count = 10 + }); + Assert.Collection(traces.PagedResult.Items, + trace => + { + AssertId("1", trace.TraceId); + AssertId("1-1", trace.FirstSpan.SpanId); + AssertId("1-1", trace.RootSpan!.SpanId); + Assert.Equal(2, trace.Spans.Count); + }); + } + + [Fact] + public void AddTraces_MultipleOutOrOrder() + { + // Arrange + var repository = CreateRepository(); + + // Act + var addContext1 = new AddContext(); + repository.AddTraces(addContext1, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: "1-1") + } + } + } + } + }); + + var addContext2 = new AddContext(); + repository.AddTraces(addContext2, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(3), endTime: s_testTime.AddMinutes(10)) + } + } + } + } + }); + + var applications = repository.GetApplications(); + Assert.Collection(applications, + app => + { + Assert.Equal("TestService", app.ApplicationName); + Assert.Equal("TestId", app.InstanceId); + }); + + var traces1 = repository.GetTraces(new GetTracesContext + { + ApplicationServiceId = applications[0].InstanceId, + StartIndex = 0, + Count = 10 + }); + Assert.Collection(traces1.PagedResult.Items, + trace => + { + AssertId("2", trace.TraceId); + AssertId("2-1", trace.FirstSpan.SpanId); + AssertId("2-1", trace.RootSpan!.SpanId); + }, + trace => + { + AssertId("1", trace.TraceId); + AssertId("1-2", trace.FirstSpan.SpanId); + Assert.Null(trace.RootSpan); + }); + + var addContext3 = new AddContext(); + repository.AddTraces(addContext3, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)) + } + } + } + } + }); + + var traces2 = repository.GetTraces(new GetTracesContext + { + ApplicationServiceId = applications[0].InstanceId, + StartIndex = 0, + Count = 10 + }); + Assert.Collection(traces2.PagedResult.Items, + trace => + { + AssertId("1", trace.TraceId); + AssertId("1-1", trace.FirstSpan.SpanId); + AssertId("1-1", trace.RootSpan!.SpanId); + }, + trace => + { + AssertId("2", trace.TraceId); + AssertId("2-1", trace.FirstSpan.SpanId); + AssertId("2-1", trace.RootSpan!.SpanId); + }); + } + + [Fact] + public void GetTraces_ReturnCopies() + { + // Arrange + var repository = CreateRepository(); + + // Act + var addContext1 = new AddContext(); + repository.AddTraces(addContext1, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10)) + } + } + } + } + }); + + var traces1 = repository.GetTraces(new GetTracesContext + { + ApplicationServiceId = null, + StartIndex = 0, + Count = 10 + }); + Assert.Collection(traces1.PagedResult.Items, + trace => + { + AssertId("1", trace.TraceId); + AssertId("1-1", trace.FirstSpan.SpanId); + AssertId("1-1", trace.RootSpan!.SpanId); + }); + + var traces2 = repository.GetTraces(new GetTracesContext + { + ApplicationServiceId = null, + StartIndex = 0, + Count = 10 + }); + Assert.NotSame(traces1.PagedResult.Items[0], traces2.PagedResult.Items[0]); + Assert.NotSame(traces1.PagedResult.Items[0].Spans[0].Trace, traces2.PagedResult.Items[0].Spans[0].Trace); + + var trace1 = repository.GetTrace(GetHexId("1"))!; + var trace2 = repository.GetTrace(GetHexId("1"))!; + Assert.NotSame(trace1, trace2); + Assert.NotSame(trace1.Spans[0].Trace, trace2.Spans[0].Trace); + } + + private static void AssertId(string expected, string actual) + { + var bytes = Convert.FromHexString(actual); + var resolvedActual = Encoding.UTF8.GetString(bytes); + + Assert.Equal(expected, resolvedActual); + } + + private static string GetHexId(string text) + { + var id = Encoding.UTF8.GetBytes(text); + return OtlpHelpers.ToHexString(id); + } + + private static InstrumentationScope CreateScope() + { + return new InstrumentationScope() { Name = "TestScope" }; + } + + private static Span CreateSpan(string traceId, string spanId, DateTime startTime, DateTime endTime, string? parentSpanId = null) + { + return new Span + { + TraceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes(traceId)), + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes(spanId)), + ParentSpanId = parentSpanId is null ? ByteString.Empty : ByteString.CopyFrom(Encoding.UTF8.GetBytes(parentSpanId)), + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + EndTimeUnixNano = DateTimeToUnixNanoseconds(endTime), + Name = "Test span" + }; + } + private static LogRecord CreateLogRecord() { return new LogRecord @@ -264,4 +527,12 @@ private static TelemetryRepository CreateRepository() { return new TelemetryRepository(new ConfigurationManager(), NullLoggerFactory.Instance); } + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + DateTime unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + TimeSpan timeSinceEpoch = dateTime.ToUniversalTime() - unixEpoch; + + return (ulong)timeSinceEpoch.Ticks / 100; + } } diff --git a/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/AspireRedisDistributedCacheExtensionsTests.cs b/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/AspireRedisDistributedCacheExtensionsTests.cs index 11d8d666225..32921a6deba 100644 --- a/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/AspireRedisDistributedCacheExtensionsTests.cs +++ b/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/AspireRedisDistributedCacheExtensionsTests.cs @@ -16,7 +16,7 @@ public void AddsRedisDistributedCacheCorrectly() { var builder = Host.CreateEmptyApplicationBuilder(null); - builder.AddRedisDistributedCache(); + builder.AddRedisDistributedCache("redis"); var host = builder.Build(); var cache = host.Services.GetRequiredService(); diff --git a/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/DistributedCacheConformanceTests.cs b/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/DistributedCacheConformanceTests.cs index 4f700d5e00e..edc8c62d652 100644 --- a/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/DistributedCacheConformanceTests.cs +++ b/tests/Aspire.StackExchange.Redis.DistributedCaching.Tests/DistributedCacheConformanceTests.cs @@ -19,11 +19,11 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action { if (key is null) { - builder.AddRedisDistributedCache(configure); + builder.AddRedisDistributedCache(configureSettings: configure); } else { - builder.AddRedisDistributedCache(key, configure); + builder.AddKeyedRedisDistributedCache(key, configure); } } @@ -34,7 +34,7 @@ public async Task WorksWithOpenTelemetryTracing() var builder = CreateHostBuilder(); - builder.AddRedisDistributedCache(); + builder.AddRedisDistributedCache("redis"); var tcs = new TaskCompletionSource(); var exportedActivities = new List(); diff --git a/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/AspireRedisOutputCacheExtensionsTests.cs b/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/AspireRedisOutputCacheExtensionsTests.cs index bdfcf6c4a7e..a94b97d9587 100644 --- a/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/AspireRedisOutputCacheExtensionsTests.cs +++ b/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/AspireRedisOutputCacheExtensionsTests.cs @@ -15,7 +15,7 @@ public void AddsRedisOutputCacheCorrectly() { var builder = Host.CreateEmptyApplicationBuilder(null); - builder.AddRedisOutputCache(); + builder.AddRedisOutputCache("redis"); var host = builder.Build(); var cacheStore = host.Services.GetRequiredService(); diff --git a/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/OutputCacheConformanceTests.cs b/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/OutputCacheConformanceTests.cs index 7d88460c2ce..d70f95607a6 100644 --- a/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/OutputCacheConformanceTests.cs +++ b/tests/Aspire.StackExchange.Redis.OutputCaching.Tests/OutputCacheConformanceTests.cs @@ -19,11 +19,11 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action { if (key is null) { - builder.AddRedisOutputCache(configure); + builder.AddRedisOutputCache(configureSettings: configure); } else { - builder.AddRedisOutputCache(key, configure); + builder.AddKeyedRedisOutputCache(key, configure); } } @@ -34,7 +34,7 @@ public async Task WorksWithOpenTelemetryTracing() var builder = CreateHostBuilder(); - builder.AddRedisOutputCache(); + builder.AddRedisOutputCache("redis"); var tcs = new TaskCompletionSource(); var exportedActivities = new List(); diff --git a/tests/Aspire.StackExchange.Redis.Tests/AspireRedisExtensionsTests.cs b/tests/Aspire.StackExchange.Redis.Tests/AspireRedisExtensionsTests.cs index 59ae62e1ffc..a12b9d47ef9 100644 --- a/tests/Aspire.StackExchange.Redis.Tests/AspireRedisExtensionsTests.cs +++ b/tests/Aspire.StackExchange.Redis.Tests/AspireRedisExtensionsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using StackExchange.Redis; @@ -18,7 +19,7 @@ public void AllowsConfigureConfigurationOptions() var builder = Host.CreateEmptyApplicationBuilder(null); AspireRedisHelpers.PopulateConfiguration(builder.Configuration); - builder.AddRedis(); + builder.AddRedis("redis"); builder.Services.Configure(options => { @@ -30,4 +31,66 @@ public void AllowsConfigureConfigurationOptions() Assert.Contains("aspire-test-user", connection.Configuration); } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + public void ReadsFromConnectionStringsCorrectly(bool useKeyed) + { + AspireRedisHelpers.SkipIfCanNotConnectToServer(); + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:myredis", AspireRedisHelpers.TestingEndpoint) + ]); + + if (useKeyed) + { + builder.AddKeyedRedis("myredis"); + } + else + { + builder.AddRedis("myredis"); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("myredis") : + host.Services.GetRequiredService(); + + Assert.Contains(AspireRedisHelpers.TestingEndpoint, connection.Configuration); + } + + [ConditionalTheory] + [InlineData(true)] + [InlineData(false)] + + public void ConnectionStringCanBeSetInCode(bool useKeyed) + { + AspireRedisHelpers.SkipIfCanNotConnectToServer(); + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:redis", "unused") + ]); + + static void SetConnectionString(StackExchangeRedisSettings settings) => settings.ConnectionString = AspireRedisHelpers.TestingEndpoint; + if (useKeyed) + { + builder.AddKeyedRedis("redis", SetConnectionString); + } + else + { + builder.AddRedis("redis", SetConnectionString); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("redis") : + host.Services.GetRequiredService(); + + Assert.Contains(AspireRedisHelpers.TestingEndpoint, connection.Configuration); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", connection.Configuration); + } } diff --git a/tests/Aspire.StackExchange.Redis.Tests/ConformanceTests.cs b/tests/Aspire.StackExchange.Redis.Tests/ConformanceTests.cs index e8c5f74767c..45d35143cac 100644 --- a/tests/Aspire.StackExchange.Redis.Tests/ConformanceTests.cs +++ b/tests/Aspire.StackExchange.Redis.Tests/ConformanceTests.cs @@ -63,11 +63,11 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action { if (key is null) { - builder.AddRedis(configure); + builder.AddRedis(configureSettings: configure); } else { - builder.AddRedis(key, configure); + builder.AddKeyedRedis(key, configure); } } From a8e6ba49d93ccfcd29b77fc51fdc4c81c4cec1d4 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 29 Sep 2023 16:02:12 -0700 Subject: [PATCH 2/2] Set executable permissions on .sh files --- build.sh | 0 dotnet.sh | 0 eng/build.sh | 0 eng/common/SetupNugetSources.sh | 0 eng/common/build.sh | 0 eng/common/cibuild.sh | 0 eng/common/cross/build-android-rootfs.sh | 0 eng/common/cross/build-rootfs.sh | 0 eng/common/cross/tizen-build-rootfs.sh | 0 eng/common/cross/tizen-fetch.sh | 0 eng/common/darc-init.sh | 0 eng/common/dotnet-install.sh | 0 eng/common/generate-sbom-prep.sh | 0 eng/common/init-tools-native.sh | 0 eng/common/internal-feed-operations.sh | 0 eng/common/msbuild.sh | 0 eng/common/native/common-library.sh | 0 eng/common/native/init-compiler.sh | 0 eng/common/native/init-distro-rid.sh | 0 eng/common/native/init-os-and-arch.sh | 0 eng/common/native/install-cmake-test.sh | 0 eng/common/native/install-cmake.sh | 0 eng/common/pipeline-logging-functions.sh | 0 eng/common/tools.sh | 0 24 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 build.sh mode change 100644 => 100755 dotnet.sh mode change 100644 => 100755 eng/build.sh mode change 100644 => 100755 eng/common/SetupNugetSources.sh mode change 100644 => 100755 eng/common/build.sh mode change 100644 => 100755 eng/common/cibuild.sh mode change 100644 => 100755 eng/common/cross/build-android-rootfs.sh mode change 100644 => 100755 eng/common/cross/build-rootfs.sh mode change 100644 => 100755 eng/common/cross/tizen-build-rootfs.sh mode change 100644 => 100755 eng/common/cross/tizen-fetch.sh mode change 100644 => 100755 eng/common/darc-init.sh mode change 100644 => 100755 eng/common/dotnet-install.sh mode change 100644 => 100755 eng/common/generate-sbom-prep.sh mode change 100644 => 100755 eng/common/init-tools-native.sh mode change 100644 => 100755 eng/common/internal-feed-operations.sh mode change 100644 => 100755 eng/common/msbuild.sh mode change 100644 => 100755 eng/common/native/common-library.sh mode change 100644 => 100755 eng/common/native/init-compiler.sh mode change 100644 => 100755 eng/common/native/init-distro-rid.sh mode change 100644 => 100755 eng/common/native/init-os-and-arch.sh mode change 100644 => 100755 eng/common/native/install-cmake-test.sh mode change 100644 => 100755 eng/common/native/install-cmake.sh mode change 100644 => 100755 eng/common/pipeline-logging-functions.sh mode change 100644 => 100755 eng/common/tools.sh diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 diff --git a/dotnet.sh b/dotnet.sh old mode 100644 new mode 100755 diff --git a/eng/build.sh b/eng/build.sh old mode 100644 new mode 100755 diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh old mode 100644 new mode 100755 diff --git a/eng/common/build.sh b/eng/common/build.sh old mode 100644 new mode 100755 diff --git a/eng/common/cibuild.sh b/eng/common/cibuild.sh old mode 100644 new mode 100755 diff --git a/eng/common/cross/build-android-rootfs.sh b/eng/common/cross/build-android-rootfs.sh old mode 100644 new mode 100755 diff --git a/eng/common/cross/build-rootfs.sh b/eng/common/cross/build-rootfs.sh old mode 100644 new mode 100755 diff --git a/eng/common/cross/tizen-build-rootfs.sh b/eng/common/cross/tizen-build-rootfs.sh old mode 100644 new mode 100755 diff --git a/eng/common/cross/tizen-fetch.sh b/eng/common/cross/tizen-fetch.sh old mode 100644 new mode 100755 diff --git a/eng/common/darc-init.sh b/eng/common/darc-init.sh old mode 100644 new mode 100755 diff --git a/eng/common/dotnet-install.sh b/eng/common/dotnet-install.sh old mode 100644 new mode 100755 diff --git a/eng/common/generate-sbom-prep.sh b/eng/common/generate-sbom-prep.sh old mode 100644 new mode 100755 diff --git a/eng/common/init-tools-native.sh b/eng/common/init-tools-native.sh old mode 100644 new mode 100755 diff --git a/eng/common/internal-feed-operations.sh b/eng/common/internal-feed-operations.sh old mode 100644 new mode 100755 diff --git a/eng/common/msbuild.sh b/eng/common/msbuild.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/common-library.sh b/eng/common/native/common-library.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/init-compiler.sh b/eng/common/native/init-compiler.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/init-distro-rid.sh b/eng/common/native/init-distro-rid.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/init-os-and-arch.sh b/eng/common/native/init-os-and-arch.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/install-cmake-test.sh b/eng/common/native/install-cmake-test.sh old mode 100644 new mode 100755 diff --git a/eng/common/native/install-cmake.sh b/eng/common/native/install-cmake.sh old mode 100644 new mode 100755 diff --git a/eng/common/pipeline-logging-functions.sh b/eng/common/pipeline-logging-functions.sh old mode 100644 new mode 100755 diff --git a/eng/common/tools.sh b/eng/common/tools.sh old mode 100644 new mode 100755