From 2a0d7d1eeeeb9c2fdd09a8850975a8d8fdbdc811 Mon Sep 17 00:00:00 2001 From: Pieter Germishuys Date: Tue, 25 Nov 2025 13:15:52 +0100 Subject: [PATCH] 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} --- .../BffServerAuthenticationStateProvider.cs | 2 +- bff/src/Bff/BffBuilderExtensions.cs | 16 ++++ .../Bff/BffEndpointRouteBuilderExtensions.cs | 2 +- bff/src/Bff/Configuration/BffOptions.cs | 5 ++ .../Bff/Configuration/DiagnosticsOptions.cs | 23 ++++++ bff/src/Bff/Diagnostics/DiagnosticContext.cs | 6 ++ .../Bff/Diagnostics/DiagnosticDataService.cs | 30 +++++++ .../AssemblyInfoDiagnosticEntry.cs | 63 +++++++++++++++ .../BasicServerInfoDiagnosticEntry.cs | 22 +++++ .../FrontendCountDiagnosticEntry.cs | 14 ++++ .../Diagnostics/DiagnosticHostedService.cs | 49 ++++++++++++ bff/src/Bff/Diagnostics/DiagnosticSummary.cs | 41 ++++++++++ bff/src/Bff/Diagnostics/DiagnosticsLog.cs | 16 ++++ bff/src/Bff/Diagnostics/IDiagnosticEntry.cs | 11 +++ .../Internal/FrontendCollection.cs | 12 --- bff/src/Bff/Licensing/LicenseValidator.cs | 22 ++++- .../Logging.g.cs | 80 +++++++++++++++++++ bff/src/Bff/ServiceCollectionExtensions.cs | 3 +- bff/test/Bff.Tests/ConventionTests.cs | 80 ++++++++++++------- .../FrontendCountDiagnosticEntryTests.cs | 45 +++++++++++ ...tionTests.VerifyPublicApi_Bff.verified.txt | 7 ++ .../Services/DiagnosticDataService.cs | 8 +- 22 files changed, 509 insertions(+), 48 deletions(-) create mode 100644 bff/src/Bff/Configuration/DiagnosticsOptions.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticContext.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticDataService.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticEntries/BasicServerInfoDiagnosticEntry.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticEntries/FrontendCountDiagnosticEntry.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticHostedService.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticSummary.cs create mode 100644 bff/src/Bff/Diagnostics/DiagnosticsLog.cs create mode 100644 bff/src/Bff/Diagnostics/IDiagnosticEntry.cs create mode 100644 bff/test/Bff.Tests/Diagnostics/FrontendCountDiagnosticEntryTests.cs diff --git a/bff/src/Bff.Blazor/BffServerAuthenticationStateProvider.cs b/bff/src/Bff.Blazor/BffServerAuthenticationStateProvider.cs index b3069ec0b..a3084be75 100644 --- a/bff/src/Bff.Blazor/BffServerAuthenticationStateProvider.cs +++ b/bff/src/Bff.Blazor/BffServerAuthenticationStateProvider.cs @@ -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 task) => _authenticationStateTask = task; diff --git a/bff/src/Bff/BffBuilderExtensions.cs b/bff/src/Bff/BffBuilderExtensions.cs index b097f4b7f..1f0263944 100644 --- a/bff/src/Bff/BffBuilderExtensions.cs +++ b/bff/src/Bff/BffBuilderExtensions.cs @@ -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 builder) where T : IBffBuilder => builder.Services.AddSingleton(); + internal static T AddDiagnostics(this T builder) + where T : IBffServicesBuilder + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(serviceProvider => new DiagnosticDataService( + serviceProvider.GetRequiredService().GetUtcNow().UtcDateTime, + serviceProvider.GetServices())); + builder.Services.AddHostedService(); + + return builder; + } internal static T AddDynamicFrontends(this T builder) where T : IBffServicesBuilder diff --git a/bff/src/Bff/BffEndpointRouteBuilderExtensions.cs b/bff/src/Bff/BffEndpointRouteBuilderExtensions.cs index 0040c2110..feab6dd34 100644 --- a/bff/src/Bff/BffEndpointRouteBuilderExtensions.cs +++ b/bff/src/Bff/BffEndpointRouteBuilderExtensions.cs @@ -201,6 +201,6 @@ public static class BffEndpointRouteBuilderExtensions internal static void CheckLicense(this IServiceProvider serviceProvider) { var license = serviceProvider.GetRequiredService(); - license.CheckLicenseValidity(); + license.CheckLicense(); } } diff --git a/bff/src/Bff/Configuration/BffOptions.cs b/bff/src/Bff/Configuration/BffOptions.cs index a605b79b3..3778706ce 100644 --- a/bff/src/Bff/Configuration/BffOptions.cs +++ b/bff/src/Bff/Configuration/BffOptions.cs @@ -179,4 +179,9 @@ public sealed class BffOptions /// public Collection AllowedSilentLoginReferers { get; } = new(); + /// + /// Options for diagnostics logging + /// + public DiagnosticsOptions Diagnostics { get; set; } = new(); + } diff --git a/bff/src/Bff/Configuration/DiagnosticsOptions.cs b/bff/src/Bff/Configuration/DiagnosticsOptions.cs new file mode 100644 index 000000000..657930edb --- /dev/null +++ b/bff/src/Bff/Configuration/DiagnosticsOptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.Bff.Configuration; + +/// +/// Options that control the way that diagnostic data is logged. +/// +public sealed class DiagnosticsOptions +{ + /// + /// Frequency at which diagnostic summaries are logged. + /// Defaults to 1 hour. + /// + public TimeSpan LogFrequency { get; set; } = TimeSpan.FromHours(1); + + /// + /// 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. + /// + public int ChunkSize { get; set; } = 1024 * 8 - 32; +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticContext.cs b/bff/src/Bff/Diagnostics/DiagnosticContext.cs new file mode 100644 index 000000000..70d84f557 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticContext.cs @@ -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); diff --git a/bff/src/Bff/Diagnostics/DiagnosticDataService.cs b/bff/src/Bff/Diagnostics/DiagnosticDataService.cs new file mode 100644 index 000000000..6c2f58848 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticDataService.cs @@ -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 entries) +{ + public async Task> GetJsonBytesAsync(CancellationToken cancellationToken = default) + { + var bufferWriter = new ArrayBufferWriter(); + 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; + } +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs b/bff/src/Bff/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs new file mode 100644 index 000000000..d13b054f3 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticEntries/AssemblyInfoDiagnosticEntry.cs @@ -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 _exactMatches = + [ + "Microsoft.AspNetCore" + ]; + + private readonly IReadOnlyList _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()! + .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 GetAssemblyInfo() + { + var assemblies = AssemblyLoadContext.Default.Assemblies + .OrderBy(a => a.FullName) + .ToList(); + + return assemblies; + } +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticEntries/BasicServerInfoDiagnosticEntry.cs b/bff/src/Bff/Diagnostics/DiagnosticEntries/BasicServerInfoDiagnosticEntry.cs new file mode 100644 index 000000000..ad09134df --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticEntries/BasicServerInfoDiagnosticEntry.cs @@ -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(); + } +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticEntries/FrontendCountDiagnosticEntry.cs b/bff/src/Bff/Diagnostics/DiagnosticEntries/FrontendCountDiagnosticEntry.cs new file mode 100644 index 000000000..b05348442 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticEntries/FrontendCountDiagnosticEntry.cs @@ -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); +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticHostedService.cs b/bff/src/Bff/Diagnostics/DiagnosticHostedService.cs new file mode 100644 index 000000000..5caf88634 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticHostedService.cs @@ -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 options, + DiagnosticSummary diagnosticsSummary, + ILogger 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); + } +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticSummary.cs b/bff/src/Bff/Diagnostics/DiagnosticSummary.cs new file mode 100644 index 000000000..6ef3d55a4 --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticSummary.cs @@ -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 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)); + } + } +} diff --git a/bff/src/Bff/Diagnostics/DiagnosticsLog.cs b/bff/src/Bff/Diagnostics/DiagnosticsLog.cs new file mode 100644 index 000000000..c410c508a --- /dev/null +++ b/bff/src/Bff/Diagnostics/DiagnosticsLog.cs @@ -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); +// } diff --git a/bff/src/Bff/Diagnostics/IDiagnosticEntry.cs b/bff/src/Bff/Diagnostics/IDiagnosticEntry.cs new file mode 100644 index 000000000..17907a344 --- /dev/null +++ b/bff/src/Bff/Diagnostics/IDiagnosticEntry.cs @@ -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); +} diff --git a/bff/src/Bff/DynamicFrontends/Internal/FrontendCollection.cs b/bff/src/Bff/DynamicFrontends/Internal/FrontendCollection.cs index 0603e8754..abc43cd46 100644 --- a/bff/src/Bff/DynamicFrontends/Internal/FrontendCollection.cs +++ b/bff/src/Bff/DynamicFrontends/Internal/FrontendCollection.cs @@ -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); } diff --git a/bff/src/Bff/Licensing/LicenseValidator.cs b/bff/src/Bff/Licensing/LicenseValidator.cs index 46ab0a35e..695cf739e 100644 --- a/bff/src/Bff/Licensing/LicenseValidator.cs +++ b/bff/src/Bff/Licensing/LicenseValidator.cs @@ -13,16 +13,34 @@ internal class LicenseValidator(ILogger 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; } } diff --git a/bff/src/Bff/Otel/Generated/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Logging.g.cs b/bff/src/Bff/Otel/Generated/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Logging.g.cs index ce65880b0..74aa4e6c2 100644 --- a/bff/src/Bff/Otel/Generated/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Logging.g.cs +++ b/bff/src/Bff/Otel/Generated/Microsoft.Gen.Logging/Microsoft.Gen.Logging.LoggingGenerator/Logging.g.cs @@ -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 + { + /// + /// Logs "Diagnostic summary ({current}/{total}): {diagnosticData}" at "Information" level. + /// + [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(); + } + + /// + /// Logs "An error occurred while logging the diagnostic summary: {Message}" at "Warning" level. + /// + [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 diff --git a/bff/src/Bff/ServiceCollectionExtensions.cs b/bff/src/Bff/ServiceCollectionExtensions.cs index 7afbd4af0..610033051 100644 --- a/bff/src/Bff/ServiceCollectionExtensions.cs +++ b/bff/src/Bff/ServiceCollectionExtensions.cs @@ -28,7 +28,8 @@ public static class ServiceCollectionExtensions return builder .AddBaseBffServices() - .AddDynamicFrontends(); + .AddDynamicFrontends() + .AddDiagnostics(); } } diff --git a/bff/test/Bff.Tests/ConventionTests.cs b/bff/test/Bff.Tests/ConventionTests.cs index 0cb150256..14003ef69 100644 --- a/bff/test/Bff.Tests/ConventionTests.cs +++ b/bff/test/Bff.Tests/ConventionTests.cs @@ -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.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(() => ctor.Invoke([])); - ex.InnerException.ShouldBeOfType().Message.ShouldContain("Can't create null value"); + ex.InnerException.ShouldBeOfType().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(); - 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 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."); } } } diff --git a/bff/test/Bff.Tests/Diagnostics/FrontendCountDiagnosticEntryTests.cs b/bff/test/Bff.Tests/Diagnostics/FrontendCountDiagnosticEntryTests.cs new file mode 100644 index 000000000..ce867f077 --- /dev/null +++ b/bff/test/Bff.Tests/Diagnostics/FrontendCountDiagnosticEntryTests.cs @@ -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")); + } +} diff --git a/bff/test/Bff.Tests/PublicApiVerificationTests.VerifyPublicApi_Bff.verified.txt b/bff/test/Bff.Tests/PublicApiVerificationTests.VerifyPublicApi_Bff.verified.txt index c5bc5fdf4..f99c10a0b 100644 --- a/bff/test/Bff.Tests/PublicApiVerificationTests.VerifyPublicApi_Bff.verified.txt +++ b/bff/test/Bff.Tests/PublicApiVerificationTests.VerifyPublicApi_Bff.verified.txt @@ -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 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 diff --git a/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs b/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs index f53ccf1ef..1baaaef1d 100644 --- a/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs +++ b/identity-server/src/IdentityServer/Services/DiagnosticDataService.cs @@ -22,7 +22,7 @@ public class DiagnosticDataService _entries = entries; } - public async Task> GetJsonBytesAsync() + public async Task> GetJsonBytesAsync(CancellationToken cancellationToken = default) { var bufferWriter = new ArrayBufferWriter(); 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 GetJsonStringAsync() + public async Task GetJsonStringAsync(CancellationToken cancellationToken = default) { - var bytes = await GetJsonBytesAsync(); + var bytes = await GetJsonBytesAsync(cancellationToken); return Encoding.UTF8.GetString(bytes.Span); } }