Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>(SignalRMethods.REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH, async correlationId =>
{
return DiagnosticLogger.Store.ToArray();
async IAsyncEnumerable<DiagnosticLogDto> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,48 @@ private void SearchSessions()
/// </summary>
private async Task ReadUserSessionLogs(Guid userSessionId)
{
var logs = await hubConnection.InvokeAsync<DiagnosticLogDto[]>("GetUserSessionLogs", userSessionId);

DiagnosticLogger.Store.Clear();
foreach (var log in logs)

var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

// Receive per-log events
var d1 = hubConnection.On<DiagnosticLogDto>(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, log =>
{
DiagnosticLogger.Store.Enqueue(log);
});

// Lifecycle events
var d2 = hubConnection.On<string>(SignalRMethods.DIAGNOSTIC_LOGS_COMPLETE, _ => completed.TrySetResult());
var d3 = hubConnection.On<string>(SignalRMethods.DIAGNOSTIC_LOGS_ABORTED, _ => completed.TrySetResult());
var d4 = hubConnection.On<string>(SignalRMethods.DIAGNOSTIC_LOGS_ERROR, _ => completed.TrySetResult());

// Join temp group and trigger the target to stream
var correlationId = await hubConnection.InvokeAsync<string?>(
"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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//+:cnd:noEmit
using Boilerplate.Shared.Dtos.Diagnostic;
using Microsoft.AspNetCore.SignalR;

namespace Boilerplate.Server.Api.SignalR;

public partial class AppHub
{
/// <summary>
/// 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.
/// </summary>
[Authorize(Policy = AppFeatures.System.ManageLogs)]
public async Task<string?> 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);

/// <summary>
/// Target client -> Hub: streams its logs once; hub forwards each item to the admin correlation group.
/// Handles completion, cancellation and errors.
/// </summary>
[HubMethodName(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STREAM)]
public async Task UploadDiagnosticLoggerStream(
string correlationId,
IAsyncEnumerable<DiagnosticLogDto> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,6 @@ public Task ChangeAuthenticationState(string? accessToken)
return ChangeAuthenticationStateImplementation(user);
}

/// <summary>
/// <inheritdoc cref="SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE"/>
/// </summary>
[Authorize(Policy = AppFeatures.System.ManageLogs)]
public async Task<DiagnosticLogDto[]> GetUserSessionLogs(Guid userSessionId, [FromServices] AppDbContext dbContext)
{
var userSessionSignalRConnectionId = await dbContext.UserSessions
.Where(us => us.Id == userSessionId)
.Select(us => us.SignalRConnectionId)
.FirstOrDefaultAsync(Context.ConnectionAborted);

if (string.IsNullOrEmpty(userSessionSignalRConnectionId))
return [];

return await Clients.Client(userSessionSignalRConnectionId).InvokeAsync<DiagnosticLogDto[]>(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, Context.ConnectionAborted);
}

private async Task ChangeAuthenticationStateImplementation(ClaimsPrincipal? user)
{
await using var scope = serviceProvider.CreateAsyncScope();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public const string REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH = nameof(REQUEST_UPLOAD_DIAGNOSTIC_LOGGER_BATCH);

/// <summary>
/// Client > Server: uploads a batch of diagnostic logs (use with a small, safe batch size).
/// </summary>
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);
}