mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
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:
parent
8c7246d826
commit
2a0d7d1eee
22 changed files with 509 additions and 48 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -201,6 +201,6 @@ public static class BffEndpointRouteBuilderExtensions
|
|||
internal static void CheckLicense(this IServiceProvider serviceProvider)
|
||||
{
|
||||
var license = serviceProvider.GetRequiredService<LicenseValidator>();
|
||||
license.CheckLicenseValidity();
|
||||
license.CheckLicense();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
}
|
||||
|
|
|
|||
23
bff/src/Bff/Configuration/DiagnosticsOptions.cs
Normal file
23
bff/src/Bff/Configuration/DiagnosticsOptions.cs
Normal 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;
|
||||
}
|
||||
6
bff/src/Bff/Diagnostics/DiagnosticContext.cs
Normal file
6
bff/src/Bff/Diagnostics/DiagnosticContext.cs
Normal 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);
|
||||
30
bff/src/Bff/Diagnostics/DiagnosticDataService.cs
Normal file
30
bff/src/Bff/Diagnostics/DiagnosticDataService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
49
bff/src/Bff/Diagnostics/DiagnosticHostedService.cs
Normal file
49
bff/src/Bff/Diagnostics/DiagnosticHostedService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
bff/src/Bff/Diagnostics/DiagnosticSummary.cs
Normal file
41
bff/src/Bff/Diagnostics/DiagnosticSummary.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
16
bff/src/Bff/Diagnostics/DiagnosticsLog.cs
Normal file
16
bff/src/Bff/Diagnostics/DiagnosticsLog.cs
Normal 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);
|
||||
// }
|
||||
11
bff/src/Bff/Diagnostics/IDiagnosticEntry.cs
Normal file
11
bff/src/Bff/Diagnostics/IDiagnosticEntry.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ public static class ServiceCollectionExtensions
|
|||
|
||||
return builder
|
||||
.AddBaseBffServices()
|
||||
.AddDynamicFrontends();
|
||||
.AddDynamicFrontends()
|
||||
.AddDiagnostics();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue