From 70353641b88c12a12e5ec65b7815a74bcbb97756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Zetterstr=C3=B6m?= Date: Thu, 30 Oct 2025 23:36:58 +0100 Subject: [PATCH 1/3] Made ReadUserSessionLogs stream logs with IAsyncEnumerable and added cancelationtoken --- .../Pages/Management/UsersPage.razor.cs | 8 ++++--- .../Boilerplate.Server.Api/SignalR/AppHub.cs | 22 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs index ba21a6aae7..c35e11417b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs @@ -191,10 +191,12 @@ private void SearchSessions() /// private async Task ReadUserSessionLogs(Guid userSessionId) { - var logs = await hubConnection.InvokeAsync("GetUserSessionLogs", userSessionId); - DiagnosticLogger.Store.Clear(); - foreach (var log in logs) + + await foreach (var log in hubConnection.StreamAsync( + "GetUserSessionLogs", + userSessionId, + cancellationToken: CurrentCancellationToken)) { DiagnosticLogger.Store.Enqueue(log); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs index dbfa6ab699..6915a37e7e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs @@ -1,4 +1,5 @@ //+:cnd:noEmit +using System.Runtime.CompilerServices; using Boilerplate.Server.Api.Controllers.Identity; using Boilerplate.Server.Api.Models.Identity; using Boilerplate.Shared.Dtos.Diagnostic; @@ -66,17 +67,30 @@ public Task ChangeAuthenticationState(string? accessToken) /// /// [Authorize(Policy = AppFeatures.System.ManageLogs)] - public async Task GetUserSessionLogs(Guid userSessionId, [FromServices] AppDbContext dbContext) + public async IAsyncEnumerable GetUserSessionLogs( + Guid userSessionId, + [EnumeratorCancellation] CancellationToken cancellationToken, + [FromServices] AppDbContext dbContext) { var userSessionSignalRConnectionId = await dbContext.UserSessions .Where(us => us.Id == userSessionId) .Select(us => us.SignalRConnectionId) - .FirstOrDefaultAsync(Context.ConnectionAborted); + .FirstOrDefaultAsync(cancellationToken); if (string.IsNullOrEmpty(userSessionSignalRConnectionId)) - return []; + yield break; - return await Clients.Client(userSessionSignalRConnectionId).InvokeAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, Context.ConnectionAborted); + var logs = await Clients.Client(userSessionSignalRConnectionId) + .InvokeAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, cancellationToken); + + if (logs is null || logs.Length is 0) + yield break; + + foreach (var log in logs) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return log; + } } private async Task ChangeAuthenticationStateImplementation(ClaimsPrincipal? user) From 36026d7db67276ac502953b3e3a3a9a6e370aa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Zetterstr=C3=B6m?= Date: Mon, 3 Nov 2025 14:52:03 +0100 Subject: [PATCH 2/3] changed to a "Group" based streaming apporach for downloading userlogs --- .../Components/AppClientCoordinator.cs | 20 +++++- .../Pages/Management/UsersPage.razor.cs | 42 +++++++++-- .../SignalR/AppHub.Diagnostics.cs | 71 +++++++++++++++++++ .../Boilerplate.Server.Api/SignalR/AppHub.cs | 32 +-------- .../Shared/Services/SharedPubSubMessages.cs | 16 +++++ 5 files changed, 145 insertions(+), 36 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs index f1dbfb7d14..ed3f5e30e2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Components.Routing; using Boilerplate.Shared.Controllers.Identity; using Boilerplate.Client.Core.Services.DiagnosticLog; +using Boilerplate.Shared.Dtos.Diagnostic; namespace Boilerplate.Client.Core.Components; @@ -220,9 +221,24 @@ private void SubscribeToSignalREventsMessages() ExceptionHandler.Handle(appProblemDetails, displayKind: ExceptionDisplayKind.NonInterrupting); })); - signalROnDisposables.Add(hubConnection.On(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, async () => + signalROnDisposables.Add(hubConnection.On(SignalRMethods.REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH, async correlationId => { - return DiagnosticLogger.Store.ToArray(); + async IAsyncEnumerable EnumerateLogs(CancellationToken ct) + { + var logs = DiagnosticLogger.Store.ToArray(); + foreach (var log in logs) + { + if (ct.IsCancellationRequested) yield break; + yield return log; + await Task.Yield(); // keep UI responsive + } + } + + await hubConnection.SendAsync( + SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STREAM, + correlationId, + EnumerateLogs(CurrentCancellationToken), + CurrentCancellationToken); })); hubConnection.Closed += HubConnectionStateChange; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs index c35e11417b..880b2b83d5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs @@ -193,12 +193,46 @@ private async Task ReadUserSessionLogs(Guid userSessionId) { DiagnosticLogger.Store.Clear(); - await foreach (var log in hubConnection.StreamAsync( - "GetUserSessionLogs", - userSessionId, - cancellationToken: CurrentCancellationToken)) + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Receive per-log events + var d1 = hubConnection.On(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, log => { DiagnosticLogger.Store.Enqueue(log); + }); + + // Lifecycle events + var d2 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_COMPLETE, _ => completed.TrySetResult()); + var d3 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_ABORTED, _ => completed.TrySetResult()); + var d4 = hubConnection.On(SignalRMethods.DIAGNOSTIC_LOGS_ERROR, _ => completed.TrySetResult()); + + // Join temp group and trigger the target to stream + var correlationId = await hubConnection.InvokeAsync( + "BeginUserSessionLogs", + userSessionId, + cancellationToken: CurrentCancellationToken); + if (string.IsNullOrEmpty(correlationId)) + { + d1.Dispose(); d2.Dispose(); d3.Dispose(); d4.Dispose(); + return; + } + + try + { + // Optional safety timeout for first item or completion + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(CurrentCancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(15)); + + await completed.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + // ignore, just end gracefully + } + finally + { + d1.Dispose(); d2.Dispose(); d3.Dispose(); d4.Dispose(); + await hubConnection.InvokeAsync("EndUserSessionLogs", correlationId); } PubSubService.Publish(ClientPubSubMessages.SHOW_DIAGNOSTIC_MODAL); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs new file mode 100644 index 0000000000..8314cd427b --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.Diagnostics.cs @@ -0,0 +1,71 @@ +//+:cnd:noEmit +using Boilerplate.Shared.Dtos.Diagnostic; +using Microsoft.AspNetCore.SignalR; + +namespace Boilerplate.Server.Api.SignalR; + +public partial class AppHub +{ + /// + /// Adds the admin caller to a temp group and requests the target client to upload logs. + /// Returns a correlationId (group name) or null if target not connected. + /// + [Authorize(Policy = AppFeatures.System.ManageLogs)] + public async Task BeginUserSessionLogs( + Guid userSessionId, + [FromServices] AppDbContext dbContext) + { + var targetConnId = await dbContext.UserSessions + .Where(us => us.Id == userSessionId) + .Select(us => us.SignalRConnectionId) + .FirstOrDefaultAsync(); + + if (string.IsNullOrEmpty(targetConnId)) + return null; + + var correlationId = Guid.NewGuid().ToString("N"); + + await Groups.AddToGroupAsync(Context.ConnectionId, correlationId, Context.ConnectionAborted); + + await Clients.Client(targetConnId) + .SendAsync(SignalRMethods.REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH, correlationId, Context.ConnectionAborted); + + return correlationId; + } + + [Authorize(Policy = AppFeatures.System.ManageLogs)] + public Task EndUserSessionLogs(string correlationId) + => Groups.RemoveFromGroupAsync(Context.ConnectionId, correlationId); + + /// + /// Target client -> Hub: streams its logs once; hub forwards each item to the admin correlation group. + /// Handles completion, cancellation and errors. + /// + [HubMethodName(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STREAM)] + public async Task UploadDiagnosticLoggerStream( + string correlationId, + IAsyncEnumerable logs) + { + try + { + await foreach (var log in logs) + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, log); + } + + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_COMPLETE, correlationId); + } + catch (OperationCanceledException) + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_ABORTED, correlationId); + } + catch + { + await Clients.Group(correlationId) + .SendAsync(SignalRMethods.DIAGNOSTIC_LOGS_ERROR, correlationId); + } + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs index 6915a37e7e..f1659807cf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs @@ -1,5 +1,7 @@ //+:cnd:noEmit +using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using System.Threading.Channels; using Boilerplate.Server.Api.Controllers.Identity; using Boilerplate.Server.Api.Models.Identity; using Boilerplate.Shared.Dtos.Diagnostic; @@ -63,36 +65,6 @@ public Task ChangeAuthenticationState(string? accessToken) return ChangeAuthenticationStateImplementation(user); } - /// - /// - /// - [Authorize(Policy = AppFeatures.System.ManageLogs)] - public async IAsyncEnumerable GetUserSessionLogs( - Guid userSessionId, - [EnumeratorCancellation] CancellationToken cancellationToken, - [FromServices] AppDbContext dbContext) - { - var userSessionSignalRConnectionId = await dbContext.UserSessions - .Where(us => us.Id == userSessionId) - .Select(us => us.SignalRConnectionId) - .FirstOrDefaultAsync(cancellationToken); - - if (string.IsNullOrEmpty(userSessionSignalRConnectionId)) - yield break; - - var logs = await Clients.Client(userSessionSignalRConnectionId) - .InvokeAsync(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, cancellationToken); - - if (logs is null || logs.Length is 0) - yield break; - - foreach (var log in logs) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return log; - } - } - private async Task ChangeAuthenticationStateImplementation(ClaimsPrincipal? user) { await using var scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs index 59a9518ee7..442f8e0d03 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Services/SharedPubSubMessages.cs @@ -44,5 +44,21 @@ public static partial class SignalRMethods /// Uploading these logs for display in the support staff's diagnostic modal log viewer aids in pinpointing the root cause of user issues during live troubleshooting. /// Another benefit of having this feature is in dev environment when you wanna see your Android, iOS logs on your desktop wide screen. /// + public const string REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH = nameof(REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH); + + /// + /// Client > Server: uploads a batch of diagnostic logs (use with a small, safe batch size). + /// + public const string UPLOAD_DIAGNOSTIC_LOGGER_BATCH = nameof(UPLOAD_DIAGNOSTIC_LOGGER_BATCH); + + // Target client -> Hub: stream logs + public const string UPLOAD_DIAGNOSTIC_LOGGER_STREAM = nameof(UPLOAD_DIAGNOSTIC_LOGGER_STREAM); + + // Hub -> Admin: deliver each log item public const string UPLOAD_DIAGNOSTIC_LOGGER_STORE = nameof(UPLOAD_DIAGNOSTIC_LOGGER_STORE); + + // Hub -> Admin: lifecycle events + public const string DIAGNOSTIC_LOGS_COMPLETE = nameof(DIAGNOSTIC_LOGS_COMPLETE); + public const string DIAGNOSTIC_LOGS_ABORTED = nameof(DIAGNOSTIC_LOGS_ABORTED); + public const string DIAGNOSTIC_LOGS_ERROR = nameof(DIAGNOSTIC_LOGS_ERROR); } From 53e6a21be2e2e493b06daf99372d9ec3b32bd051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Zetterstr=C3=B6m?= Date: Mon, 3 Nov 2025 14:59:55 +0100 Subject: [PATCH 3/3] removed usings --- .../src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs index f1659807cf..cbf3081a9c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/SignalR/AppHub.cs @@ -1,7 +1,4 @@ //+:cnd:noEmit -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using System.Threading.Channels; using Boilerplate.Server.Api.Controllers.Identity; using Boilerplate.Server.Api.Models.Identity; using Boilerplate.Shared.Dtos.Diagnostic;