First stab at perf tests. (#2070)

* First stab at perf tests.

* Update bff/hosts/Hosts.IdentityServer/Config.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* reading values from env vars

* dotnet format

* cleanup

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erwin van der Valk 2025-06-24 15:35:15 +02:00 committed by GitHub
parent aa6ebd68e5
commit 7702c8d758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1172 additions and 193 deletions

4
.gitignore vendored
View file

@ -226,4 +226,6 @@ artifacts
#exclude for verify.tests
*.received.txt
*.Artifacts/
*.Artifacts/
reports

View file

@ -86,11 +86,15 @@
<PackageVersion Include="Microsoft.IdentityModel.Logging" Condition="'$(TargetFramework)' == 'net9.0'" Version="8.0.1" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Condition="'$(TargetFramework)' == 'net8.0'" Version="7.1.2" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Condition="'$(TargetFramework)' == 'net9.0'" Version="8.0.1" />
<PackageVersion Include="Microsoft.Net.Http.Headers" Version="9.0.6" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.NETCore.Jit" Version="2.0.8" />
<PackageVersion Include="Microsoft.Playwright.Xunit" Version="1.50.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="MinVer" Version="6.0.0" />
<PackageVersion Include="NBomber" Version="6.0.2" />
<PackageVersion Include="NBomber.Http" Version="6.0.2" />
<PackageVersion Include="NBomber.Sinks.Timescale" Version="0.8.0" />
<PackageVersion Include="NSubstitute" Version="5.1.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.11.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.0" />
@ -135,4 +139,4 @@
<PackageVersion Include="System.Drawing.Common" Version="6.0.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.2" />
</ItemGroup>
</Project>
</Project>

View file

@ -3,6 +3,7 @@
"path": "..\\products.slnx",
"projects": [
"bff\\performance\\Bff.Benchmarks\\Bff.Benchmarks.csproj",
"bff\\performance\\Bff.Performance\\Bff.Performance.csproj",
".github\\workflow-gen\\workflow-gen.csproj",
"bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent.Client\\Hosts.Bff.Blazor.PerComponent.Client.csproj",
"bff\\hosts\\Blazor\\PerComponent\\Hosts.Bff.Blazor.PerComponent\\Hosts.Bff.Blazor.PerComponent.csproj",
@ -12,6 +13,7 @@
"bff\\hosts\\Hosts.Bff.DPoP\\Hosts.Bff.DPoP.csproj",
"bff\\hosts\\Hosts.Bff.EF\\Hosts.Bff.EF.csproj",
"bff\\hosts\\Hosts.Bff.InMemory\\Hosts.Bff.InMemory.csproj",
"bff\\hosts\\Hosts.Bff.Performance\\Hosts.Bff.Performance.csproj",
"bff\\hosts\\Hosts.Bff.MultiFrontend\\Hosts.Bff.MultiFrontend.csproj",
"bff\\hosts\\Hosts.IdentityServer\\Hosts.IdentityServer.csproj",
"bff\\hosts\\Hosts.ServiceDefaults\\Hosts.ServiceDefaults.csproj",

View file

@ -17,9 +17,6 @@
<ItemGroup>
<ProjectReference Include="..\Hosts.Bff.MultiFrontend\Hosts.Bff.MultiFrontend.csproj" />
<ProjectReference Include="..\Hosts.ServiceDefaults\Hosts.ServiceDefaults.csproj" IsAspireProjectResource="false" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\migrations\UserSessionDb\UserSessionDb.csproj" />
<ProjectReference Include="..\RemoteApis\Hosts.RemoteApi.DPoP\Hosts.RemoteApi.DPoP.csproj" />
<ProjectReference Include="..\RemoteApis\Hosts.RemoteApi.Isolated\Hosts.RemoteApi.Isolated.csproj" />
@ -27,6 +24,7 @@
<ProjectReference Include="..\Hosts.Bff.DPoP\Hosts.Bff.DPoP.csproj" />
<ProjectReference Include="..\Hosts.Bff.EF\Hosts.Bff.EF.csproj" />
<ProjectReference Include="..\Hosts.Bff.InMemory\Hosts.Bff.InMemory.csproj" />
<ProjectReference Include="..\Hosts.Bff.Performance\Hosts.Bff.Performance.csproj" />
<ProjectReference Include="..\Blazor\PerComponent\Hosts.Bff.Blazor.PerComponent.Client\Hosts.Bff.Blazor.PerComponent.Client.csproj" />
<ProjectReference Include="..\Blazor\PerComponent\Hosts.Bff.Blazor.PerComponent\Hosts.Bff.Blazor.PerComponent.csproj" />
<ProjectReference Include="..\Blazor\WebAssembly\Hosts.Bff.Blazor.WebAssembly.Client\Hosts.Bff.Blazor.WebAssembly.Client.csproj" />

View file

@ -18,6 +18,14 @@ var bff = builder.AddProject<Projects.Hosts_Bff_InMemory>(AppHostServices.Bff)
.WithAwaitedReference(api)
;
var perf = builder.AddProject<Projects.Hosts_Bff_Performance>(AppHostServices.BffPerf)
.WithExternalHttpEndpoints()
.WithEndpoint(6100, isProxied: false, scheme: "https", name: "idsrv")
.WithEndpoint(6001, isProxied: false, scheme: "https", name: "api")
.WithEndpoint(6002, isProxied: false, scheme: "https", name: "single")
.WithEndpoint(6003, isProxied: false, scheme: "https", name: "multi")
;
var bffMulti = builder.AddProject<Projects.Hosts_Bff_MultiFrontend>(AppHostServices.BffMultiFrontend)
.WithExternalHttpEndpoints()
.WithUrl("https://app1.localhost:5005", "https://app1.localhost:5005")
@ -58,6 +66,7 @@ builder.AddProject<Projects.UserSessionDb>(AppHostServices.Migrations);
idServer
.WithReference(bff)
.WithReference(perf)
.WithReference(bffMulti)
.WithReference(bffEf)
.WithReference(bffBlazorPerComponent)
@ -74,8 +83,6 @@ builder.AddProject<BffRemoteApi>(AppHostServices.TemplateBffRemote, launchProfil
builder.AddProject<BffBlazorAutoRenderMode>(AppHostServices.TemplateBffBlazor);
builder.Build().Run();
public static class Extensions

View file

@ -0,0 +1,121 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using Hosts.ServiceDefaults;
namespace IdentityServer;
public static class Config
{
public static IEnumerable<IdentityResource> IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
];
public static IEnumerable<ApiScope> ApiScopes =>
[
new("api", ["name"]),
new("scope-for-isolated-api", ["name"]),
];
public static IEnumerable<ApiResource> ApiResources =>
[
new("urn:isolated-api", "isolated api")
{
RequireResourceIndicator = true,
Scopes = { "scope-for-isolated-api" }
}
];
// Get the BFF URL from the service discovery system. Then use this for building the redirect urls etc..
private static Uri bffUrl = ServiceDiscovery.ResolveService(AppHostServices.Bff);
private static Uri bffMultiFrontendUrl = ServiceDiscovery.ResolveService(AppHostServices.BffMultiFrontend);
private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop);
private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf);
private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent);
private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable<Client> Clients =>
[
BuildClient("bff.perf",
ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"),
ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"),
new Uri("https://app1.localhost:6002")
),
BuildClient("bff", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffUrl),
BuildClient("bff.multi-frontend.default", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffMultiFrontendUrl),
BuildClient("bff.multi-frontend.config", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri(bffMultiFrontendUrl, "from-config")),
BuildClient("bff.multi-frontend.with-path", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri(bffMultiFrontendUrl, "with-path")),
BuildClient("bff.multi-frontend.with-domain", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri("https://app1.localhost:5005")),
BuildClient("bff.dpop", client =>
{
client.RequireDPoP = true;
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffDPopUrl),
BuildClient("bff.ef", client =>
{
client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel";
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffEfUrl),
BuildClient("blazor", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035"))
];
private static Client BuildClient(string clientId, Action<Client> postConfigure, params Uri[] uris)
{
var client = BuildClient(clientId, uris);
postConfigure(client);
return client;
}
private static Client BuildClient(string clientId, params Uri[] uris) => new Client
{
ClientId = clientId,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(),
FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(),
PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(),
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api" },
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
};
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Hosts.Bff.Performance.Services;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
public static class DefaultOpenIdConfiguration
{
public static void Apply(OpenIdConnectOptions options, BffSettings settings)
{
options.Authority = settings.IdentityServerUrl.ToString();
// confidential client using code flow + PKCE
options.ClientId = "bff.perf";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.MapInboundClaims = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
// request scopes + refresh tokens
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("api");
options.Scope.Add("offline_access");
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.IdentityServer" />
<PackageReference Include="Duende.IdentityModel" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\bff\hosts\Hosts.ServiceDefaults\Hosts.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\src\Bff.Yarp\Bff.Yarp.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Hosts.Bff.Performance.Services;
var builder = Host.CreateApplicationBuilder();
builder.Services.Configure<ApiSettings>(builder.Configuration);
builder.Services.Configure<BffSettings>(builder.Configuration);
builder.Services.Configure<IdentityServerSettings>(builder.Configuration);
builder.Services.AddHostedService<ApiHostedService>();
builder.Services.AddHostedService<IdentityServerService>();
builder.Services.AddHostedService<SingleFrontendBffService>();
builder.Services.AddHostedService<MultiFrontendBffService>();
// Add services to the container.
var app = builder.Build();
// Configure the HTTP request pipeline.
// spin up multiple applications:
// Plain yarp
// single frontend
// multi-frontend
// bff with server side EF sessions
app.Run();

View file

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Hosts.Bff.Performance": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"ApiUrl": "https://localhost:6100",
"IdentityServerUrl": "https://localhost:6001",
"BffUrl1": "https://localhost:6002",
"BffUrl2": "https://localhost:6003",
"BffUrl3": "https://app1.localhost:6003"
},
"applicationUrl": "https://localhost:6100;https://localhost:6001;https://localhost:6002;https://localhost:6003;https://app1.localhost:6003"
}
}
}

View file

@ -0,0 +1,28 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.Extensions.Options;
namespace Hosts.Bff.Performance.Services;
public class ApiHostedService(IOptions<ApiSettings> apiSettings) : BackgroundService
{
public ApiSettings Settings { get; } = apiSettings.Value;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var builder = WebApplication.CreateBuilder();
builder.AddServiceDefaults();
// Configure Kestrel to listen on the specified Uri
builder.WebHost.UseUrls(Settings.ApiUrl.ToString());
var app = builder.Build();
app.UseRouting();
app.MapGet("/", () => "ok");
return app.RunAsync(stoppingToken);
}
}

View file

@ -0,0 +1,10 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Bff.Performance.Services;
public class ApiSettings
{
public required Uri ApiUrl { get; set; }
public required Uri IdentityServerUrl { get; set; }
}

View file

@ -0,0 +1,85 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff;
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Yarp;
using Microsoft.Extensions.Options;
namespace Hosts.Bff.Performance.Services;
public abstract class BffService(string[] urlConfigKeys, IConfiguration config, IOptions<BffSettings> bffSettings) : BackgroundService
{
public IConfiguration Config { get; } = config;
public BffSettings Settings { get; } = bffSettings.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var urls = urlConfigKeys
.Select(x => Config[x])
.OfType<string>()
.ToArray();
var builder = WebApplication.CreateBuilder();
builder.AddServiceDefaults();
// Configure Kestrel to listen on the specified Uri
builder.WebHost.UseUrls(urls);
builder.Services.AddAuthorization();
ConfigureServices(builder.Services);
var bffBuilder = builder.Services.AddBff()
.AddRemoteApis();
ConfigureBff(bffBuilder);
// Build and run the web app
var app = builder.Build();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseBff();
ConfigureApp(app);
app.MapGet("/local_anon", () => DateTime.Now.ToString("s"))
.AsBffApiEndpoint()
.AllowAnonymous();
app.MapGet("/local", () => DateTime.Now.ToString("s"))
.RequireAuthorization()
.AsBffApiEndpoint();
app.MapRemoteBffApiEndpoint("/remote_anon", Settings.ApiUrl)
.WithAccessToken(RequiredTokenType.None);
app.MapRemoteBffApiEndpoint("/remote_user", Settings.ApiUrl)
.WithAccessToken();
app.MapRemoteBffApiEndpoint("/remote_client", Settings.ApiUrl)
.WithAccessToken(RequiredTokenType.Client);
// Todo: Make sure this is mapped implicitly
app.MapBffManagementEndpoints();
await app.RunAsync(stoppingToken);
}
public virtual void ConfigureServices(IServiceCollection services)
{
}
public virtual void ConfigureBff(BffBuilder bff)
{
}
public virtual void ConfigureApp(WebApplication app)
{
}
}

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Bff.Performance.Services;
public class BffServiceSettings
{
public required string Uri { get; set; }
public Uri ApiUrl { get; } = new Uri("https://localhost:5999");
}

View file

@ -0,0 +1,10 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Bff.Performance.Services;
public class BffSettings
{
public required Uri IdentityServerUrl { get; set; }
public required Uri ApiUrl { get; set; }
}

View file

@ -0,0 +1,123 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Claims;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Test;
using IdentityServer;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Hosts.Bff.Performance.Services;
public class IdentityServerService(IOptions<IdentityServerSettings> settings, IConfiguration config) : BackgroundService
{
public IdentityServerSettings Settings { get; } = settings.Value;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var builder = WebApplication.CreateBuilder();
builder.AddServiceDefaults();
// Configure Kestrel to listen on the specified Uri
builder.WebHost.UseUrls(Settings.IdentityServerUrl);
builder.Services.AddAuthorization();
builder.Services.AddHttpLogging();
var isBuilder = builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
options.EmitStaticAudienceClaim = true;
})
.AddTestUsers([new TestUser()
{
SubjectId = "bob",
Username = "bob",
Password = "bob",
Claims = [
new Claim(JwtClaimTypes.Name, "Bob Smith"),
new Claim(JwtClaimTypes.GivenName, "Bob"),
new Claim(JwtClaimTypes.FamilyName, "Smith"),
new Claim(JwtClaimTypes.Email, "bob@duende.com")
],
IsActive = true
}])
;
// in-memory, code config
isBuilder.AddInMemoryIdentityResources(Config.IdentityResources);
isBuilder.AddInMemoryApiScopes(Config.ApiScopes);
var bffUrls = config.AsEnumerable()
.Where(x => x.Key.StartsWith("BffUrl"))
.Select(x => x.Value)
.OfType<string>();
isBuilder.AddInMemoryClients([new Client
{
ClientId = "bff.perf",
ClientSecrets = [new Secret("secret".Sha256())],
RedirectUris = bffUrls.Select(x => x.TrimEnd('/') + "/signin-oidc").ToArray(),
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api" },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
}]);
isBuilder.AddInMemoryApiResources(Config.ApiResources);
var app = builder.Build();
app.UseHttpLogging();
app.UseDeveloperExceptionPage();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapGet("/", () => "identity server");
app.MapGet("/account/login", async ctx =>
{
var props = new AuthenticationProperties();
await ctx.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(JwtClaimTypes.Subject, "bob"),
new Claim(JwtClaimTypes.Name, "bob")
],
"login", "name", "role")), props);
});
app.MapGet("/account/logout", async ctx =>
{
// signout as if the user were prompted
await ctx.SignOutAsync();
var logoutId = ctx.Request.Query["logoutId"];
var interaction = ctx.RequestServices.GetRequiredService<IIdentityServerInteractionService>();
var signOutContext = await interaction.GetLogoutContextAsync(logoutId);
ctx.Response.Redirect(signOutContext.PostLogoutRedirectUri ?? "/");
});
return app.RunAsync(stoppingToken);
}
}

View file

@ -0,0 +1,9 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Hosts.Bff.Performance.Services;
public class IdentityServerSettings
{
public required string IdentityServerUrl { get; set; }
}

View file

@ -0,0 +1,24 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff;
using Duende.Bff.DynamicFrontends;
using Microsoft.Extensions.Options;
namespace Hosts.Bff.Performance.Services;
public class MultiFrontendBffService(IConfiguration config, IOptions<BffSettings> settings) : BffService(["BffUrl2", "BffUrl3"], config, settings)
{
public override void ConfigureServices(IServiceCollection services)
{
}
public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings))
.AddFrontends(new BffFrontend(BffFrontendName.Parse("default")))
// Note, in order for this to work, we'll need to inject this as config
.AddFrontends(new BffFrontend(BffFrontendName.Parse("app1")).MappedToOrigin(Config.GetValue<Origin>("BffUrl3") ?? throw new InvalidOperationException("BFFUrl3 is null")));
public override void ConfigureApp(WebApplication app) => app.MapGet("/", (SelectedFrontend selectedFrontend) => "multi - " + selectedFrontend.Get().Name);
}

View file

@ -0,0 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff;
using Duende.Bff.DynamicFrontends;
using Microsoft.Extensions.Options;
namespace Hosts.Bff.Performance.Services;
public class SingleFrontendBffService(IConfiguration config, IOptions<BffSettings> settings) : BffService(["BffUrl1"], config, settings)
{
public override void ConfigureServices(IServiceCollection services)
{
}
public override void ConfigureBff(BffBuilder bff) => bff.WithDefaultOpenIdConnectOptions(o => DefaultOpenIdConfiguration.Apply(o, Settings))
.AddFrontends(new BffFrontend(BffFrontendName.Parse("default")));
public override void ConfigureApp(WebApplication app) => app.MapGet("/", () => "single");
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -36,203 +36,86 @@ public static class Config
private static Uri bffDPopUrl = ServiceDiscovery.ResolveService(AppHostServices.BffDpop);
private static Uri bffEfUrl = ServiceDiscovery.ResolveService(AppHostServices.BffEf);
private static Uri bffBlazorPerComponentUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorPerComponent);
private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly);
public static IEnumerable<Client> Clients =>
private static Uri bffBlazorWebAssemblyUrl = ServiceDiscovery.ResolveService(AppHostServices.BffBlazorWebassembly); public static IEnumerable<Client> Clients =>
[
new Client
{
ClientId = "bff",
ClientSecrets = { new Secret("secret".Sha256()) },
BuildClient("bff.perf",
ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "single"),
ServiceDiscovery.ResolveService(AppHostServices.BffPerf, "multi"),
new Uri("https://app1.localhost:6002")
),
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
BuildClient("bff", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffUrl),
RedirectUris = { $"{bffUrl}signin-oidc" },
FrontChannelLogoutUri = $"{bffUrl}signout-oidc",
PostLogoutRedirectUris = { $"{bffUrl}signout-callback-oidc" },
BuildClient("bff.multi-frontend.default", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffMultiFrontendUrl),
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
BuildClient("bff.multi-frontend.config", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri(bffMultiFrontendUrl, "from-config")),
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
},
new Client
{
ClientId = "bff.multi-frontend.default",
ClientSecrets = { new Secret("secret".Sha256()) },
BuildClient("bff.multi-frontend.with-path", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri(bffMultiFrontendUrl, "with-path")),
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"{bffMultiFrontendUrl}signin-oidc" },
FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc",
PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" },
BuildClient("bff.multi-frontend.with-domain", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, new Uri("https://app1.localhost:5005")),
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
BuildClient("bff.dpop", client =>
{
client.RequireDPoP = true;
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffDPopUrl),
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
},
new Client
{
ClientId = "bff.multi-frontend.config",
ClientSecrets = { new Secret("secret".Sha256()) },
BuildClient("bff.ef", client =>
{
client.BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel";
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffEfUrl),
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"{bffMultiFrontendUrl}from-config/signin-oidc" },
FrontChannelLogoutUri = $"{bffMultiFrontendUrl}from-config/signout-oidc",
PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}from-config/signout-callback-oidc" },
BuildClient("blazor", client =>
{
client.AllowedScopes.Add("scope-for-isolated-api");
}, bffBlazorWebAssemblyUrl, bffBlazorPerComponentUrl, new Uri("https://localhost:7035"))
];
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
},
new Client
{
ClientId = "bff.multi-frontend.with-path",
ClientSecrets = { new Secret("secret".Sha256()) },
private static Client BuildClient(string clientId, Action<Client> postConfigure, params Uri[] uris)
{
var client = BuildClient(clientId, uris);
postConfigure(client);
return client;
}
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"{bffMultiFrontendUrl}with-path/signin-oidc" },
FrontChannelLogoutUri = $"{bffMultiFrontendUrl}signout-oidc",
PostLogoutRedirectUris = { $"{bffMultiFrontendUrl}signout-callback-oidc" },
private static Client BuildClient(string clientId, params Uri[] uris) => new Client
{
ClientId = clientId,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = uris.Select(u => new Uri(u, "signin-oidc").ToString()).ToList(),
FrontChannelLogoutUri = new Uri(uris.First(), "signout-oidc").ToString(),
PostLogoutRedirectUris = uris.Select(u => new Uri(u, "signout-callback-oidc").ToString()).ToList(),
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
},
new Client
{
ClientId = "bff.multi-frontend.with-domain",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"https://app1.localhost:5005/signin-oidc" },
FrontChannelLogoutUri = $"https://app1.localhost:5005/signout-oidc",
PostLogoutRedirectUris = { $"https://app1.localhost:5005/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
},
new Client
{
ClientId = "bff.dpop",
ClientSecrets = { new Secret("secret".Sha256()) },
RequireDPoP = true,
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"{bffDPopUrl}signin-oidc" },
FrontChannelLogoutUri = $"{bffDPopUrl}signout-oidc",
PostLogoutRedirectUris = { $"{bffDPopUrl}signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
// Intentionally set lifetime short to see what happens when access and refresh tokens expire
AccessTokenLifetime = 15,
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60
},
new Client
{
ClientId = "bff.ef",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris = { $"{bffEfUrl}signin-oidc" },
FrontChannelLogoutUri = $"{bffEfUrl}signout-oidc",
BackChannelLogoutUri = $"{bffEfUrl}bff/backchannel",
PostLogoutRedirectUris = { $"{bffEfUrl}signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
// Intentionally set lifetime short to see what happens when access and refresh tokens expire
AccessTokenLifetime = 15,
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60
},
new Client
{
ClientId = "blazor",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes =
{
GrantType.AuthorizationCode,
GrantType.ClientCredentials,
OidcConstants.GrantTypes.TokenExchange
},
RedirectUris =
{
$"{bffBlazorWebAssemblyUrl}signin-oidc",
$"{bffBlazorPerComponentUrl}signin-oidc",
"https://localhost:7035/signin-oidc"
},
PostLogoutRedirectUris =
{
$"{bffBlazorWebAssemblyUrl}signout-callback-oidc", $"{bffBlazorPerComponentUrl}signout-callback-oidc"
},
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api", "scope-for-isolated-api" },
// Intentionally set lifetime short to see what happens when access and refresh tokens expire
AccessTokenLifetime = 15,
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60
}
];
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "api" },
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 60,
AccessTokenLifetime = 15 // Force refresh
};
}

View file

@ -4,6 +4,7 @@
namespace Hosts.ServiceDefaults;
public static class AppHostServices
{
public const string BffPerf = "bff-perf";
public const string IdentityServer = "identity-server";
public const string Api = "api";
public const string IsolatedApi = "api-isolated";
@ -24,6 +25,7 @@ public static class AppHostServices
Api,
IsolatedApi,
Bff,
BffPerf,
BffEf,
BffBlazorWebassembly,
BffBlazorPerComponent,
@ -39,13 +41,12 @@ public static class AppHostServices
public static class ServiceDiscovery
{
public static Uri ResolveService(string serviceName)
public static Uri ResolveService(string serviceName, string appName = "https")
{
var scheme = "https";
var host = serviceName;
// Compose the environment variable key
var envVarKey = $"services__{host}__{scheme}__0";
var envVarKey = $"services__{host}__{appName}__0";
// Try to get the value from environment variables
var value = Environment.GetEnvironmentVariable(envVarKey);

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Net.Http.Headers" />
<PackageReference Include="NBomber" />
<PackageReference Include="NBomber.Http" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,53 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
// full login / logout flow
// Api calls:
// plain yarp call
// Local api
// multi-frontend index.html response
// remote api user token
// - yarp
// - plain
// - single multi-frontend
// - 100 multi-frontends
// - server side sessions
// - large response payload
// remote api client credentials token
// - yarp
// - plain
// - single multi-frontend
// - 100 multi-frontends
// - large response payload
// with distributed cache + short lifecycle
using Bff.Performance.Scenarios.Bff;
using Microsoft.Extensions.Configuration;
using NBomber.Contracts.Stats;
using NBomber.CSharp;
using NBomber.Http;
var config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddCommandLine(args)
.AddJsonFile("appsettings.json", optional: true)
.Build();
var urls = config.GetSection("BffUrls").Get<Uri[]>();
if (urls == null || urls.Length == 0)
{
throw new InvalidOperationException("BffUrls configuration is missing or empty.");
}
NBomberRunner
.RegisterScenarios(new BffScenarios(urls).Scenarios)
.WithWorkerPlugins(new HttpMetricsPlugin())
.WithReportingInterval(TimeSpan.FromSeconds(5))
.WithReportFormats(
ReportFormat.Csv, ReportFormat.Html
)
.Run(args);

View file

@ -0,0 +1,11 @@
{
"profiles": {
"Bff.Performance": {
"commandName": "Project",
"environmentVariables": {
"BffUrls__0": "https://localhost:6002",
"BffUrls__1": "https://localhost:6003"
}
}
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using NBomber.Contracts;
using NBomber.CSharp;
namespace Bff.Performance.Scenarios;
public abstract class BaseScenario(string name)
{
public virtual Task Init(IScenarioInitContext c) => Task.CompletedTask;
public string Name => GetType().Name + "_" + name;
public HttpStatusCode SuccessStatusCode { get; set; } = HttpStatusCode.OK;
public async Task<IResponse> Run(IScenarioContext context)
{
var result = await RunScenario(context);
if (result.StatusCode == SuccessStatusCode)
{
return Response.Ok(result.StatusCode, result.StatusCode.ToString(), result.Content.Headers.ContentLength ?? 0);
}
return Response.Fail(result.StatusCode, result.StatusCode.ToString(), "Returned an unexpected httpresult", result.Content.Headers.ContentLength ?? 0);
}
public abstract Task<HttpResponseMessage> RunScenario(IScenarioContext context);
public TestClient Client { get; set; } = null!;
public static implicit operator ScenarioProps(BaseScenario scenario) => Scenario.Create(scenario.Name, scenario.Run)
.WithoutWarmUp()
.WithLoadSimulations(
Simulation.Inject(rate: 10,
interval: TimeSpan.FromSeconds(1),
during: TimeSpan.FromSeconds(30))
)
.WithInit(scenario.Init);
}

View file

@ -0,0 +1,18 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using NBomber.Contracts;
namespace Bff.Performance.Scenarios.Bff;
public class BffScenarios
{
public ScenarioProps[] Scenarios;
public BffScenarios(Uri[] baseUris) => Scenarios = baseUris.SelectMany(x => new ScenarioProps[]
{
new CallAnonymousLocalApi(x),
new CallAuthorizedLocalApi(x)
})
.ToArray();
}

View file

@ -0,0 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using NBomber.Contracts;
namespace Bff.Performance.Scenarios.Bff;
public class CallAnonymousLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString())
{
public override Task Init(IScenarioInitContext c)
{
Client = TestClient.Create(baseUri);
return Task.CompletedTask;
}
public override async Task<HttpResponseMessage> RunScenario(IScenarioContext context) => await Client.GetAsync("/local_anon");
}
public class CallAuthorizedLocalApi(Uri baseUri) : BaseScenario(baseUri.ToString())
{
public override async Task Init(IScenarioInitContext c)
{
Client = TestClient.Create(baseUri);
await Client.TriggerLogin();
}
public override async Task<HttpResponseMessage> RunScenario(IScenarioContext context)
{
var result = await Client.GetAsync("/local");
return result;
}
}

View file

@ -0,0 +1,124 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Bff.Performance.TestInfra;
namespace Bff.Performance;
public class TestClient(Uri baseAddress, CookieHandler cookies, HttpMessageHandler handler)
{
public HttpClient Client = new(handler)
{
BaseAddress = baseAddress
};
public void ClearCookies() => cookies.ClearCookies();
public static TestClient Create(Uri baseAddress, CookieContainer? cookies = null)
{
var inner = new SocketsHttpHandler
{
// We need to disable cookies and follow redirects
// because we do this manually (see below).
UseCookies = false,
AllowAutoRedirect = false
};
var cookieHandler = new CookieHandler(inner, cookies);
var handler = new AutoFollowRedirectHandler((_) => { })
{
InnerHandler = cookieHandler
};
return new TestClient(baseAddress, cookieHandler, handler);
}
public Task<HttpResponseMessage> GetAsync(string path, Dictionary<string, string>? headers = null, CancellationToken ct = default)
{
var request = BuildRequest(HttpMethod.Get, path, headers);
return Client.SendAsync(request);
}
public Task<HttpResponseMessage> PostAsync(string path, object? body, Dictionary<string, string>? headers = null, CancellationToken ct = default)
{
var request = BuildRequest(HttpMethod.Post, path, headers);
request.Content = JsonContent.Create(body);
return Client.SendAsync(request);
}
public Task<HttpResponseMessage> PostAsync<T>(Uri path, T body, Dictionary<string, string>? headers = null, CancellationToken ct = default)
where T : HttpContent
{
var request = BuildRequest(HttpMethod.Post, path.ToString(), headers);
request.Content = body;
return Client.SendAsync(request);
}
private static HttpRequestMessage BuildRequest(HttpMethod httpMethod, string path,
Dictionary<string, string>? headers = null)
{
var request = new HttpRequestMessage(httpMethod, path);
if (headers == null)
{
request.Headers.Add("x-csrf", "1");
}
else
{
foreach (var header in headers)
{
request.Headers.Add(header.Key, header.Value);
}
}
return request;
}
public async Task<HttpResponseMessage> TriggerLogin(string userName = "alice", string password = "alice", CancellationToken ct = default) => await GetAsync("/bff/login");
public async Task<HttpResponseMessage> TriggerLogout()
{
// To trigger a logout, we need the logout claim
var userClaims = await GetUserClaims();
var logoutLink = userClaims.FirstOrDefault(x => x.Type == "bff:logout_url")
?? throw new InvalidOperationException("Failed to find logout link claim");
return await GetAsync(logoutLink.Value.ToString()!);
}
public async Task<UserClaim[]> GetUserClaims()
{
var userClaimsString = await GetStringAsync("/bff/user");
var userClaims = JsonSerializer.Deserialize<UserClaim[]>(userClaimsString, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
return userClaims;
}
private async Task<string> GetStringAsync(string path)
{
var response = await GetAsync(path);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to get string from {path}. Status code: {response.StatusCode}");
}
return await response.Content.ReadAsStringAsync();
}
public async Task<HttpResponseMessage> InvokeApi(string url) => await GetAsync(url);
public record UserClaim
{
public required string Type { get; init; }
public required object Value { get; init; }
}
}

View file

@ -0,0 +1,43 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
namespace Bff.Performance.TestInfra;
public class AutoFollowRedirectHandler(Action<string> writeOutput) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var previousUri = request.RequestUri;
for (var i = 0; i < 20; i++)
{
var result = await base.SendAsync(request, cancellationToken);
if ((result.StatusCode == HttpStatusCode.Found || result.StatusCode == HttpStatusCode.RedirectKeepVerb) && result.Headers.Location != null)
{
writeOutput($"Redirecting from {previousUri} to {result.Headers.Location}");
var newUri = result.Headers.Location;
if (!newUri.IsAbsoluteUri)
{
newUri = new Uri(previousUri!, newUri);
}
var headers = request.Headers;
request = new HttpRequestMessage(HttpMethod.Get, newUri);
foreach (var header in headers)
{
request.Headers.Add(header.Key, header.Value);
}
previousUri = request.RequestUri;
continue;
}
return result;
}
throw new InvalidOperationException("Keeps redirecting forever");
}
}

View file

@ -0,0 +1,81 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Bff.Performance.TestInfra;
public class CloningHttpMessageHandler(HttpClient innerHttpClient) : HttpMessageHandler
{
private readonly HttpClient _innerHttpClient =
innerHttpClient ?? throw new ArgumentNullException(nameof(innerHttpClient));
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
// Clone the incoming request
var clonedRequest = await CloneHttpRequestMessageAsync(request);
// Send the cloned request using the inner HttpClient
var response = await _innerHttpClient.SendAsync(clonedRequest, cancellationToken);
// Clone the response and return it
return await CloneHttpResponseMessageAsync(response);
}
private async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage original)
{
var cloned = new HttpRequestMessage(original.Method, original.RequestUri)
{
Version = original.Version
};
// Copy the content if present
if (original.Content != null)
{
//var memoryStream = new MemoryStream();
//await original.Content.CopyToAsync(memoryStream);
//memoryStream.Position = 0;
//cloned.Content = new StreamContent(memoryStream);
cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync());
// Copy headers from the original content to the cloned content
foreach (var header in original.Content.Headers)
{
cloned.Content.Headers.Add(header.Key, header.Value);
}
}
// Copy headers
foreach (var header in original.Headers)
{
cloned.Headers.Add(header.Key, header.Value);
}
return cloned;
}
private async Task<HttpResponseMessage> CloneHttpResponseMessageAsync(HttpResponseMessage original)
{
var cloned = new HttpResponseMessage(original.StatusCode)
{
Version = original.Version,
ReasonPhrase = original.ReasonPhrase,
RequestMessage = original.RequestMessage
};
cloned.Content = new StreamContent(await original.Content.ReadAsStreamAsync());
// Copy headers from the original content to the cloned content
foreach (var header in original.Content.Headers)
{
cloned.Content.Headers.Add(header.Key, header.Value);
}
// Copy headers
foreach (var header in original.Headers)
{
cloned.Headers.Add(header.Key, header.Value);
}
return cloned;
}
}

View file

@ -0,0 +1,43 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using Microsoft.Net.Http.Headers;
namespace Bff.Performance.TestInfra;
public class CookieHandler(HttpMessageHandler innerHandler, CookieContainer? cookies = null) : DelegatingHandler(innerHandler)
{
public void ClearCookies() => CookieContainer = new CookieContainer();
public CookieContainer CookieContainer { get; private set; } = cookies ?? new CookieContainer();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var requestUri = request.RequestUri;
var header = CookieContainer.GetCookieHeader(requestUri!);
if (!string.IsNullOrEmpty(header))
{
request.Headers.Add(HeaderNames.Cookie, header);
}
var response = await base.SendAsync(request, ct);
if (response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders))
{
foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList()))
{
var cookie = new Cookie(cookieHeader.Name.Value!,
cookieHeader.Value.Value,
cookieHeader.Path.Value);
if (cookieHeader.Expires.HasValue)
{
cookie.Expires = cookieHeader.Expires.Value.UtcDateTime;
}
CookieContainer.Add(requestUri!, cookie);
}
}
return response;
}
}

View file

@ -0,0 +1,54 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Bff.Performance.TestInfra;
public class RequestLoggingHandler(
ILogger<RequestLoggingHandler> log,
Func<HttpRequestMessage, bool> shouldLog)
: DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!shouldLog(request))
{
return await base.SendAsync(request, cancellationToken);
}
var stopwatch = Stopwatch.StartNew();
try
{
var result = await base.SendAsync(request, cancellationToken);
log.LogInformation("Executing {method} on {url} returned {statuscode} in {ms} ms",
request.Method,
request.RequestUri,
result.StatusCode,
stopwatch.ElapsedMilliseconds);
return result;
}
catch (OperationCanceledException)
{
log.LogWarning("Executing {method} on {url} was cancelled in {ms} ms",
request.Method,
request.RequestUri,
stopwatch.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
log.LogWarning(ex,
"Exception while executing {method} on {url} in {ms} ms",
request.Method,
request.RequestUri,
stopwatch.ElapsedMilliseconds);
throw;
}
}
}

View file

@ -17,6 +17,7 @@
<Folder Name="/bff/" />
<Folder Name="/bff/performance/">
<Project Path="bff/performance/Bff.Benchmarks/Bff.Benchmarks.csproj" />
<Project Path="bff/performance/Bff.Performance/Bff.Performance.csproj"/>
</Folder>
<Folder Name="/bff/hosts/">
<Project Path="bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj" />
@ -26,6 +27,7 @@
<Project Path="bff/hosts/Hosts.Bff.MultiFrontend/Hosts.Bff.MultiFrontend.csproj" />
<Project Path="bff/hosts/Hosts.IdentityServer/Hosts.IdentityServer.csproj" />
<Project Path="bff/hosts/Hosts.ServiceDefaults/Hosts.ServiceDefaults.csproj" />
<Project Path="bff/hosts/Hosts.Bff.Performance/Hosts.Bff.Performance.csproj" />
</Folder>
<Folder Name="/bff/hosts/Blazor/" />
<Folder Name="/bff/hosts/Blazor/PerComponent/">