Print Diagnostic Usage Summary

This includes
 - The number of current BFF Frontends
 - Basic Server Information
 - Duende Assembly names and their versions

We will stick with the same "schema" of what Identity Server is logging
out.
e.g.
	Diagnostic summary (1/1): {"FrontendCount":0}
This commit is contained in:
Pieter Germishuys 2025-11-25 13:15:52 +01:00
parent 8c7246d826
commit 2a0d7d1eee
22 changed files with 509 additions and 48 deletions

View file

@ -68,7 +68,7 @@ internal sealed class BffServerAuthenticationStateProvider : RevalidatingServerA
AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
licenseValidator.CheckLicenseValidity();
licenseValidator.CheckLicense();
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task) => _authenticationStateTask = task;

View file

@ -5,6 +5,8 @@ using Duende.AccessTokenManagement.OpenIdConnect;
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Builder;
using Duende.Bff.Configuration;
using Duende.Bff.Diagnostics;
using Duende.Bff.Diagnostics.DiagnosticEntries;
using Duende.Bff.DynamicFrontends;
using Duende.Bff.DynamicFrontends.Internal;
using Duende.Bff.Endpoints;
@ -113,6 +115,20 @@ public static class BffBuilderExtensions
internal static void AddBffMetrics<T>(T builder) where T : IBffBuilder =>
builder.Services.AddSingleton<BffMetrics>();
internal static T AddDiagnostics<T>(this T builder)
where T : IBffServicesBuilder
{
builder.Services.AddSingleton<IDiagnosticEntry, BasicServerInfoDiagnosticEntry>();
builder.Services.AddSingleton<IDiagnosticEntry, AssemblyInfoDiagnosticEntry>();
builder.Services.AddSingleton<IDiagnosticEntry, FrontendCountDiagnosticEntry>();
builder.Services.AddSingleton<DiagnosticSummary>();
builder.Services.AddSingleton(serviceProvider => new DiagnosticDataService(
serviceProvider.GetRequiredService<TimeProvider>().GetUtcNow().UtcDateTime,
serviceProvider.GetServices<IDiagnosticEntry>()));
builder.Services.AddHostedService<DiagnosticHostedService>();
return builder;
}
internal static T AddDynamicFrontends<T>(this T builder)
where T : IBffServicesBuilder

View file

@ -201,6 +201,6 @@ public static class BffEndpointRouteBuilderExtensions
internal static void CheckLicense(this IServiceProvider serviceProvider)
{
var license = serviceProvider.GetRequiredService<LicenseValidator>();
license.CheckLicenseValidity();
license.CheckLicense();
}
}

View file

@ -179,4 +179,9 @@ public sealed class BffOptions
/// </summary>
public Collection<string> AllowedSilentLoginReferers { get; } = new();
/// <summary>
/// Options for diagnostics logging
/// </summary>
public DiagnosticsOptions Diagnostics { get; set; } = new();
}

View file

@ -0,0 +1,23 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Configuration;
/// <summary>
/// Options that control the way that diagnostic data is logged.
/// </summary>
public sealed class DiagnosticsOptions
{
/// <summary>
/// Frequency at which diagnostic summaries are logged.
/// Defaults to 1 hour.
/// </summary>
public TimeSpan LogFrequency { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Max size of diagnostic data log message chunks in kilobytes.
/// Defaults to 8160 bytes. 8 KB is a conservative limit for the max size of a log message that is imposed by
/// some logging tools. We take 32 bytes less than that to allow for additional formatting of the log message.
/// </summary>
public int ChunkSize { get; set; } = 1024 * 8 - 32;
}

View file

@ -0,0 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.Bff.Diagnostics;
internal record DiagnosticContext(DateTime ServerStartTime, DateTime CurrentServerTime);

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Buffers;
using System.Text.Json;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticDataService(DateTime serverStartTime, IEnumerable<IDiagnosticEntry> entries)
{
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync(CancellationToken cancellationToken = default)
{
var bufferWriter = new ArrayBufferWriter<byte>();
await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false });
writer.WriteStartObject();
var diagnosticContext = new DiagnosticContext(serverStartTime, DateTime.UtcNow);
foreach (var diagnosticEntry in entries)
{
diagnosticEntry.Write(diagnosticContext, writer);
}
writer.WriteEndObject();
await writer.FlushAsync(cancellationToken);
return bufferWriter.WrittenMemory;
}
}

View file

@ -0,0 +1,63 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text.Json;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class AssemblyInfoDiagnosticEntry : IDiagnosticEntry
{
private readonly IReadOnlyList<string> _exactMatches =
[
"Microsoft.AspNetCore"
];
private readonly IReadOnlyList<string> _startsWithMatches =
[
"Duende.",
"Microsoft.AspNetCore.Components.",
"Microsoft.AspNetCore.Authentication.",
"Microsoft.IdentityModel.",
"System.IdentityModel.",
"System.IdentityModel",
"Microsoft.EntityFrameworkCore",
];
public void Write(DiagnosticContext _, Utf8JsonWriter writer)
{
var assemblies = GetAssemblyInfo();
writer.WriteStartObject("AssemblyInfo");
writer.WriteString("DotNetVersion", RuntimeInformation.FrameworkDescription);
writer.WriteString("BFF",
typeof(DiagnosticHostedService).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
.InformationalVersion);
writer.WriteStartArray("Assemblies");
foreach (var assembly in assemblies.Where(assembly => assembly.GetName().Name != null &&
(_exactMatches.Contains(assembly.GetName().Name) ||
_startsWithMatches.Any(prefix =>
assembly.GetName().Name!.StartsWith(prefix,
StringComparison.Ordinal)))))
{
writer.WriteStartObject();
writer.WriteString("Name", assembly.GetName().Name);
writer.WriteString("Version", assembly.GetName().Version?.ToString() ?? "Unknown");
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static List<Assembly> GetAssemblyInfo()
{
var assemblies = AssemblyLoadContext.Default.Assemblies
.OrderBy(a => a.FullName)
.ToList();
return assemblies;
}
}

View file

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Net;
using System.Text.Json;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class BasicServerInfoDiagnosticEntry(TimeProvider timeProvider)
: IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer)
{
writer.WriteStartObject("BasicServerInfo");
writer.WriteString("HostName", Dns.GetHostName());
writer.WriteString("ServerStartTime", context.ServerStartTime.ToString("o"));
writer.WriteString("CurrentServerTime", timeProvider.GetUtcNow().UtcDateTime.ToString("o"));
writer.WriteEndObject();
}
}

View file

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text.Json;
using Duende.Bff.DynamicFrontends;
namespace Duende.Bff.Diagnostics.DiagnosticEntries;
internal class FrontendCountDiagnosticEntry(IFrontendCollection frontendCollection)
: IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer) =>
writer.WriteNumber("FrontendCount", frontendCollection.Count);
}

View file

@ -0,0 +1,49 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticHostedService(
IOptions<BffOptions> options,
DiagnosticSummary diagnosticsSummary,
ILogger<DiagnosticHostedService> logger,
TimeProvider timeProvider) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(options.Value.Diagnostics.LogFrequency, timeProvider);
try
{
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await diagnosticsSummary.PrintSummaryAsync(stoppingToken);
}
#pragma warning disable CA1031
// Catching general exceptions here to prevent the host from crashing.
catch (Exception ex)
#pragma warning restore CA1031
{
logger.FailedToLogDiagnosticsSummary(ex.Message);
}
}
}
catch (OperationCanceledException)
{
// When stopping this hosted service, "await timer.WaitForNextTickAsync(stoppingToken)" can throw an OperationCanceledException.
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await diagnosticsSummary.PrintSummaryAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text;
using Duende.Bff.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Duende.Bff.Diagnostics;
internal class DiagnosticSummary(
DiagnosticDataService diagnosticDataService,
IOptions<BffOptions> options,
ILoggerFactory loggerFactory)
{
private readonly ILogger _logger = loggerFactory.CreateLogger("Duende.BFF.Diagnostics.Summary");
public async Task PrintSummaryAsync(CancellationToken cancellationToken = default)
{
var bffOptions = options.Value;
var jsonMemory = await diagnosticDataService.GetJsonBytesAsync(cancellationToken);
var span = jsonMemory.Span;
var chunkSize = bffOptions.Diagnostics.ChunkSize;
if (span.Length > chunkSize)
{
var totalChunks = (span.Length + bffOptions.Diagnostics.ChunkSize - 1) / chunkSize;
for (var i = 0; i < totalChunks; i++)
{
var offset = i * chunkSize;
var length = Math.Min(chunkSize, span.Length - offset);
var chunk = span.Slice(offset, length);
_logger.DiagnosticSummaryLogged(i + 1, totalChunks, Encoding.UTF8.GetString(chunk));
}
}
else
{
_logger.DiagnosticSummaryLogged(1, 1, Encoding.UTF8.GetString(span));
}
}
}

View file

@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
// using Microsoft.Extensions.Logging;
//
// namespace Duende.Bff.Diagnostics;
//
// internal static partial class DiagnosticsLog
// {
// [LoggerMessage(1, LogLevel.Information, "Diagnostic summary ({current}/{total}): {diagnosticData}")]
// internal static partial void DiagnosticSummaryLogged(this ILogger logger, int current, int total,
// string diagnosticData);
//
// [LoggerMessage(2, LogLevel.Warning, "An error occurred while logging the diagnostic summary: {Message}")]
// internal static partial void FailedToLogDiagnosticsSummary(this ILogger logger, string message);
// }

View file

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Text.Json;
namespace Duende.Bff.Diagnostics;
internal interface IDiagnosticEntry
{
public void Write(DiagnosticContext context, Utf8JsonWriter writer);
}

View file

@ -61,15 +61,6 @@ internal class FrontendCollection : IDisposable, IFrontendCollection
.Where(frontend => oldFrontends.All(x => x.Name != frontend.Name))
.ToArray();
//TODO: potential place to log out number of frontends
// var totalFrontends = oldFrontends.Length - removedFrontends.Length;
// foreach (var frontend in addedFrontends)
// {
// _licenseValidator.LogFrontendAdded(frontend.Name, ++totalFrontends);
// }
Interlocked.Exchange(ref _frontends, newFrontends);
}
@ -190,9 +181,6 @@ internal class FrontendCollection : IDisposable, IFrontendCollection
}
else
{
//TODO: potential place to log out number of frontends
// _licenseValidator.LogFrontendAdded(frontend.Name, _frontends.Length);
OnFrontendAdded(frontend);
}

View file

@ -13,16 +13,34 @@ internal class LicenseValidator(ILogger<LicenseValidator> logger, License licens
{
}
public void CheckLicenseValidity()
private bool? _licenseCheckResult;
public bool CheckLicense()
{
if (_licenseCheckResult != null)
{
return _licenseCheckResult.Value;
}
_licenseCheckResult = CheckLicenseValidity();
return _licenseCheckResult.Value;
}
private bool CheckLicenseValidity()
{
if (!license.IsConfigured)
{
logger.NoValidLicense(LogLevel.Error);
return false;
}
if (license.Expiration <= timeProvider.GetUtcNow())
{
logger.LicenseHasExpired(LogLevel.Error, license.Expiration, license.ContactInfo ?? "", license.CompanyName ?? "");
logger.LicenseHasExpired(LogLevel.Error, license.Expiration, license.ContactInfo ?? "",
license.CompanyName ?? "");
return false;
}
return true;
}
}

View file

@ -5,6 +5,86 @@ using Microsoft.AspNetCore.Http;
#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103
namespace Duende.Bff.Diagnostics
{
internal static class DiagnosticsLog
{
/// <summary>
/// Logs "Diagnostic summary ({current}/{total}): {diagnosticData}" at "Information" level.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "10.0.0.0")]
internal static void DiagnosticSummaryLogged(this global::Microsoft.Extensions.Logging.ILogger logger, int current, int total, string diagnosticData)
{
if (!logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
{
return;
}
var state = global::Microsoft.Extensions.Logging.LoggerMessageHelper.ThreadLocalState;
_ = state.ReserveTagSpace(4);
state.TagArray[3] = new("{OriginalFormat}", "Diagnostic summary ({current}/{total}): {diagnosticData}");
state.TagArray[2] = new("current", current);
state.TagArray[1] = new("total", total);
state.TagArray[0] = new("diagnosticData", diagnosticData);
logger.Log(
global::Microsoft.Extensions.Logging.LogLevel.Information,
new(1, nameof(DiagnosticSummaryLogged)),
state,
null,
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "10.0.0.0")] static string (s, _) =>
{
var current = s.TagArray[2].Value;
var total = s.TagArray[1].Value;
var diagnosticData = s.TagArray[0].Value ?? "(null)";
#if NET
return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"Diagnostic summary ({current}/{total}): {diagnosticData}");
#else
return global::System.FormattableString.Invariant($"Diagnostic summary ({current}/{total}): {diagnosticData}");
#endif
});
state.Clear();
}
/// <summary>
/// Logs "An error occurred while logging the diagnostic summary: {Message}" at "Warning" level.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "10.0.0.0")]
internal static void FailedToLogDiagnosticsSummary(this global::Microsoft.Extensions.Logging.ILogger logger, string message)
{
if (!logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Warning))
{
return;
}
var state = global::Microsoft.Extensions.Logging.LoggerMessageHelper.ThreadLocalState;
_ = state.ReserveTagSpace(2);
state.TagArray[1] = new("{OriginalFormat}", "An error occurred while logging the diagnostic summary: {Message}");
state.TagArray[0] = new("Message", message);
logger.Log(
global::Microsoft.Extensions.Logging.LogLevel.Warning,
new(2, nameof(FailedToLogDiagnosticsSummary)),
state,
null,
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "10.0.0.0")] static string (s, _) =>
{
var message = s.TagArray[0].Value ?? "(null)";
#if NET
return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"An error occurred while logging the diagnostic summary: {message}");
#else
return global::System.FormattableString.Invariant($"An error occurred while logging the diagnostic summary: {message}");
#endif
});
state.Clear();
}
}
}
namespace Duende.Bff.Licensing
{
internal static class LicensingLogMessages

View file

@ -28,7 +28,8 @@ public static class ServiceCollectionExtensions
return builder
.AddBaseBffServices()
.AddDynamicFrontends();
.AddDynamicFrontends()
.AddDiagnostics();
}
}

View file

@ -7,6 +7,7 @@ using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Blazor;
using Duende.Bff.Blazor.Client;
using Duende.Bff.Blazor.Client.Internals;
using Duende.Bff.Diagnostics;
using Duende.Bff.DynamicFrontends.Internal;
using Duende.Bff.Endpoints.Internal;
using Duende.Bff.EntityFramework;
@ -25,13 +26,14 @@ public class ConventionTests(ITestOutputHelper output)
public static readonly Assembly BffBlazorClientAssembly = typeof(BffBlazorClientOptions).Assembly;
public static readonly Assembly BffEntityFrameworkAssembly = typeof(UserSessionEntity).Assembly;
public static readonly Assembly BffYarpAssembly = typeof(BffYarpTransformBuilder).Assembly;
public static readonly Type[] AllTypes =
BffAssembly.GetTypes()
.Union(BffBlazorAssembly.GetTypes())
.Union(BffBlazorClientAssembly.GetTypes())
.Union(BffEntityFrameworkAssembly.GetTypes())
.Union(BffYarpAssembly.GetTypes())
.ToArray();
.ToArray();
[Fact]
public void All_strongly_typed_strings_Have_private_value()
@ -66,7 +68,8 @@ public class ConventionTests(ITestOutputHelper output)
{
var buildMethod = type.GetMethods(BindingFlags.Static)
.FirstOrDefault(m => m.Name == "Create");
buildMethod.ShouldBeNull("The IStonglyTypedString defines a Create method, but it should be implemented explicitly on the interface, not on the type. \r\n IE: " +
buildMethod.ShouldBeNull(
"The IStonglyTypedString defines a Create method, but it should be implemented explicitly on the interface, not on the type. \r\n IE: " +
" static AccessTokenString IStonglyTypedString<AccessTokenString>.Create(string result) => new(result);");
}
}
@ -83,9 +86,11 @@ public class ConventionTests(ITestOutputHelper output)
// Try to invoke the constructor with a value and expect an exception
var ex = Should.Throw<TargetInvocationException>(() => ctor.Invoke([]));
ex.InnerException.ShouldBeOfType<InvalidOperationException>().Message.ShouldContain("Can't create null value");
ex.InnerException.ShouldBeOfType<InvalidOperationException>().Message
.ShouldContain("Can't create null value");
}
}
[Fact]
public void All_strongly_typed_strings_should_have_only_expected_constructors()
{
@ -96,7 +101,8 @@ public class ConventionTests(ITestOutputHelper output)
var ctors = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// There must be exactly two constructors
ctors.Length.ShouldBe(2, $"{type.Name} should have exactly two constructors: one public parameterless and one private with a single string parameter.");
ctors.Length.ShouldBe(2,
$"{type.Name} should have exactly two constructors: one public parameterless and one private with a single string parameter.");
// Find the public parameterless constructor
var publicParameterlessCtor = ctors.FirstOrDefault(c =>
@ -111,7 +117,8 @@ public class ConventionTests(ITestOutputHelper output)
c.GetParameters().Length == 1 &&
c.GetParameters()[0].ParameterType == typeof(string));
privateStringCtor.ShouldNotBeNull($"{type.Name} should have a private constructor with a single string parameter.");
privateStringCtor.ShouldNotBeNull(
$"{type.Name} should have a private constructor with a single string parameter.");
}
}
@ -135,13 +142,15 @@ public class ConventionTests(ITestOutputHelper output)
[Fact()]
public void All_types_not_in_Internal_namespace_should_be_sealed_or_static()
{
Type[] exclusions = [
Type[] exclusions =
[
typeof(SessionDbContext),
typeof(SessionDbContext<>),
typeof(UserSessionEntity),
typeof(UserSession),
typeof(UserSessionUpdate),
typeof(AccessTokenRetrievalError)];
typeof(AccessTokenRetrievalError)
];
// Find all types NOT in a '.Internal' namespace
var nonInternalTypes = AllTypes
@ -169,7 +178,8 @@ public class ConventionTests(ITestOutputHelper output)
public void All_async_methods_should_end_with_Async_and_have_cancellation_token_as_last_parameter()
{
var failures = new List<string>();
Type[] exclusions = [
Type[] exclusions =
[
typeof(BffAuthenticationSchemeProvider),
typeof(BffOpenIdConnectEvents),
typeof(BffAuthenticationService),
@ -182,8 +192,8 @@ public class ConventionTests(ITestOutputHelper output)
typeof(BffCacheClearingHostedService),
typeof(SessionDbContext),
typeof(SessionDbContext<>),
typeof(DiagnosticHostedService),
typeof(ServerSideTokenStore), // This one needs to be removed after move to ATM 4.0
];
foreach (var type in AllTypes
.Where(t => !exclusions.Contains(t))
@ -206,7 +216,8 @@ public class ConventionTests(ITestOutputHelper output)
var parameters = method.GetParameters();
if (parameters.Length == 0 || parameters.Last().ParameterType != typeof(CT))
{
failures.Add($"{type.FullName}.{method.Name}: Async method should have a CT as the last parameter.");
failures.Add(
$"{type.FullName}.{method.Name}: Async method should have a CT as the last parameter.");
}
}
}
@ -218,18 +229,18 @@ public class ConventionTests(ITestOutputHelper output)
failures.ShouldBeEmpty();
}
public static bool IsInternal(Type type)
{
if (type.IsNested)
{
return true;
}
return type.IsNestedPrivate || type.IsNotPublic;
}
#nullable disable
[Fact]
public void AccessTokenManagement_is_not_exposed()
@ -252,7 +263,8 @@ public class ConventionTests(ITestOutputHelper output)
}
// Check public members for forbidden types
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly);
var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static |
BindingFlags.DeclaredOnly);
foreach (var member in members)
{
switch (member)
@ -263,30 +275,36 @@ public class ConventionTests(ITestOutputHelper output)
{
break;
}
if (IsForbiddenType(method.ReturnType))
{
errors.Add($"{type.FullName}.{method.Name} returns forbidden type {method.ReturnType.FullName}");
errors.Add(
$"{type.FullName}.{method.Name} returns forbidden type {method.ReturnType.FullName}");
}
foreach (var param in method.GetParameters())
{
if (IsForbiddenType(param.ParameterType))
{
errors.Add($"{type.FullName}.{method.Name} parameter '{param.Name}' is forbidden type {param.ParameterType.FullName}");
errors.Add(
$"{type.FullName}.{method.Name} parameter '{param.Name}' is forbidden type {param.ParameterType.FullName}");
}
}
break;
case PropertyInfo prop:
if (IsForbiddenType(prop.PropertyType))
{
errors.Add($"{type.FullName}.{prop.Name} property is forbidden type {prop.PropertyType.FullName}");
errors.Add(
$"{type.FullName}.{prop.Name} property is forbidden type {prop.PropertyType.FullName}");
}
break;
case FieldInfo field:
if (IsForbiddenType(field.FieldType))
{
errors.Add($"{type.FullName}.{field.Name} field is forbidden type {field.FieldType.FullName}");
errors.Add(
$"{type.FullName}.{field.Name} field is forbidden type {field.FieldType.FullName}");
}
break;
@ -294,7 +312,8 @@ public class ConventionTests(ITestOutputHelper output)
if (IsForbiddenType(evt.EventHandlerType!))
{
errors.Add($"{type.FullName}.{evt.Name} event is forbidden type {evt.EventHandlerType.FullName}");
errors.Add(
$"{type.FullName}.{evt.Name} event is forbidden type {evt.EventHandlerType.FullName}");
}
break;
@ -331,10 +350,12 @@ public class ConventionTests(ITestOutputHelper output)
}
}
}
if (t.IsArray)
{
return IsForbiddenType(t.GetElementType()!);
}
return false;
}
}
@ -346,15 +367,16 @@ public class ConventionTests(ITestOutputHelper output)
// Find all types implementing IStringValue<TSelf>
var stringValueTypes =
AllTypes.Where(t => t.IsValueType && !t.IsAbstract)
.SelectMany(t =>
t.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IStronglyTypedValue<>)
&& i.GenericTypeArguments[0] == t)
.Select(_ => t))
.Distinct()
.ToList();
.SelectMany(t =>
t.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IStronglyTypedValue<>)
&& i.GenericTypeArguments[0] == t)
.Select(_ => t))
.Distinct()
.ToList();
return stringValueTypes;
}
[Fact]
public void All_interface_async_methods_should_have_cancellation_token_with_default()
{
@ -368,18 +390,22 @@ public class ConventionTests(ITestOutputHelper output)
var parameters = method.GetParameters();
if (parameters.Length == 0)
{
failures.Add($"{type.FullName}.{method.Name}: Async method should have a CancellationToken parameter with a default value.");
failures.Add(
$"{type.FullName}.{method.Name}: Async method should have a CancellationToken parameter with a default value.");
continue;
}
var ctParam = parameters.Last();
if (ctParam.ParameterType != typeof(System.Threading.CancellationToken))
{
failures.Add($"{type.FullName}.{method.Name}: Last parameter should be CancellationToken.");
continue;
}
if (!ctParam.HasDefaultValue)
{
failures.Add($"{type.FullName}.{method.Name}: CancellationToken parameter should have a default value.");
failures.Add(
$"{type.FullName}.{method.Name}: CancellationToken parameter should have a default value.");
}
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.Bff.DynamicFrontends;
using Duende.Bff.Tests.TestInfra;
using Xunit.Abstractions;
namespace Duende.Bff.Tests.Diagnostics;
public class FrontendCountDiagnosticEntryTests(ITestOutputHelper testOutputHelper) : BffTestBase(testOutputHelper)
{
[Fact]
public async Task Should_print_the_number_of_frontends_during_defined_interval()
{
Bff.OnConfigureBffOptions += options =>
{
options.Diagnostics.LogFrequency = TimeSpan.FromHours(1);
};
await InitializeAsync();
AdvanceClock(TimeSpan.FromHours(1));
await Task.Delay(100);
var bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("\"FrontendCount\":0"));
AddOrUpdateFrontend(new BffFrontend
{
Name = BffFrontendName.Parse("frontend1"),
});
AddOrUpdateFrontend(new BffFrontend
{
Name = BffFrontendName.Parse("frontend2"),
});
AdvanceClock(TimeSpan.FromHours(1));
await Task.Delay(100);
bffLogMessages = Context.LogMessages.ToString().Split(Environment.NewLine).Where(x => x.StartsWith("bff"));
bffLogMessages.ShouldContain(x => x.Contains("\"FrontendCount\":2"));
}
}

View file

@ -309,6 +309,7 @@ namespace Duende.Bff.Configuration
public System.Net.Http.HttpMessageHandler? BackchannelHttpHandler { get; set; }
public bool BackchannelLogoutAllUserSessions { get; set; }
public Duende.Bff.AccessTokenManagement.DPoPProofKey? DPoPJsonWebKey { get; set; }
public Duende.Bff.Configuration.DiagnosticsOptions Diagnostics { get; set; }
public System.Collections.Generic.ICollection<string> DiagnosticsEnvironments { get; }
public Microsoft.AspNetCore.Http.PathString DiagnosticsPath { get; }
public Duende.Bff.Configuration.DisableAntiForgeryCheck DisableAntiForgeryCheck { get; set; }
@ -344,6 +345,12 @@ namespace Duende.Bff.Configuration
public Duende.Bff.AccessTokenManagement.Resource? Resource { get; init; }
public Duende.Bff.AccessTokenManagement.Scheme? SignInScheme { get; init; }
}
public sealed class DiagnosticsOptions
{
public DiagnosticsOptions() { }
public int ChunkSize { get; set; }
public System.TimeSpan LogFrequency { get; set; }
}
public delegate bool DisableAntiForgeryCheck(Microsoft.AspNetCore.Http.HttpContext context);
}
namespace Duende.Bff.DynamicFrontends

View file

@ -22,7 +22,7 @@ public class DiagnosticDataService
_entries = entries;
}
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync()
public async Task<ReadOnlyMemory<byte>> GetJsonBytesAsync(CancellationToken cancellationToken = default)
{
var bufferWriter = new ArrayBufferWriter<byte>();
await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false });
@ -37,14 +37,14 @@ public class DiagnosticDataService
writer.WriteEndObject();
await writer.FlushAsync();
await writer.FlushAsync(cancellationToken);
return bufferWriter.WrittenMemory;
}
public async Task<string> GetJsonStringAsync()
public async Task<string> GetJsonStringAsync(CancellationToken cancellationToken = default)
{
var bytes = await GetJsonBytesAsync();
var bytes = await GetJsonBytesAsync(cancellationToken);
return Encoding.UTF8.GetString(bytes.Span);
}
}