DEV Community

Stewart Celani
Stewart Celani

Posted on

Combine Blazor WebAssembly Client and Server Logs: Two-way log streaming with NLog and SignalR

Last time, in Streaming Blazor (ASP.Net Core) Logs to the Browser with a custom NLog Target and SignalR, we set up a fresh Blazor WebAssembly project to stream server logs to the browser:

Last time in Streaming Blazor (ASP.Net Core) Logs to the Browser with a custom NLog Target and SignalR

At the end of that post I concluded "this is much more useful for Blazor Server projects as everything is happening on the server already". Over the next couple days it continued to bug me as I pondered how nice it would be to have combined Client and Server logs in Blazor WASM like in Blazor Server.

The exact setup I'm going for is as a development environment tool:

  • Server project streams logs to Browser JavaScript console
  • Client project streams logs to the Server project console

On the server this will give the same experience as a Blazor Server project, with frontend and backend logs being combined, while also adding the ability to access those same logs from the browsers JavaScript console.


Repository with commits at each step can be found here: GitHub - stewartcelani/WasmTwoWayLogging

Feel free to skip to the end of the article to see a GIF of this working in action.


Step 7: Fixing the Server project to access the LoggingHub directly instead of via Microsoft.AspNetCore.SignalR.Client

It felt a bit clunky using SignalR via Server\Logging\LoggingHubConnection.cs to send SignalR messages from the server to itself so luckily there is a way to send messages from outside the hub via the SignalR HubContext. All is not wasted though, we will need to use the LoggingHubConnection.cs later from the Client project to get client logs to the server.

Not including the deletion of Server\Logging\LoggingHubConnection.cs we have 4 changed files.

Server\Services\DebugService.cs is where we will send log messages to the browser directly via the LoggingHub context made available via ASP.NET Core dependency injection in the constructor.

We need a way to get a reference to the DebugService into the Server\Logging\LoggingHubTarget.cs. To do this, in Server\Program.cs, we can get a reference to a service as at app start up and then pass this reference into Server\Logging\Logger.cs::Configure.

Server\Logging\Logger.cs passes the reference through to the LoggingHubTarget.

A very slimmed down Server\Logging\LoggingHubTarget.cs simply renders the logEvent to a string and passes it to DebugService::LogToBrowser.

// Server\Services\DebugService.cs
using Microsoft.AspNetCore.SignalR;
using WasmTwoWayLogging.Server.Hubs;

namespace WasmTwoWayLogging.Server.Services
{
    public class DebugService
    {
        private readonly IHubContext<LoggingHub> _hubContext;
        public DebugService(IHubContext<LoggingHub> hubContext)
        {
            _hubContext = hubContext;
        }
        public async Task LogToBrowser(string logMessage)
        {
            await _hubContext.Clients.All.SendAsync("Log", logMessage);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// Server\Logging\Logger.cs
using NLog;
using NLog.Config;
using NLog.Targets;
using WasmTwoWayLogging.Server.Services;

namespace WasmTwoWayLogging.Server.Logging
{
    public static class Logger
    {
        public static NLog.Logger Log = LogManager.GetCurrentClassLogger();

        public static void Configure(DebugService debugService)
        {
            LoggingConfiguration config = new LoggingConfiguration();
            string layout = "${longdate}|Server|${level}|${callsite}|Line:${callsite-linenumber}|${message}             ${all-event-properties} ${exception:format=tostring}";

            // Log to console
            ColoredConsoleTarget consoleTarget = new ColoredConsoleTarget()
            {
                UseDefaultRowHighlightingRules = true,
                Layout = layout
            };
            config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: consoleTarget);

            // Log to file (minLevel: Info)
            FileTarget infoFileTarget = new FileTarget("info")
            {
                FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.log",
                Layout = layout
            };
            config.AddRule(minLevel: NLog.LogLevel.Info, maxLevel: NLog.LogLevel.Fatal, target: infoFileTarget);

            // Log to file (minLevel: Trace)
            FileTarget traceFileTarget = new FileTarget("trace")
            {
                FileName = "${basedir}\\Logging\\${date:format=yyyy-MM-dd}.trace.log",
                Layout = layout
            };
            config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: traceFileTarget);

            // Log to SignalR LoggingHub
            var loggingHubTarget = new LoggingHubTarget(debugService)
            {
                Layout = layout
            };
            config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: loggingHubTarget);

            LogManager.Configuration = config;
            Log = LogManager.GetCurrentClassLogger();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// Server\Logging\LoggingHubTarget.cs
using NLog;
using NLog.Targets;
using WasmTwoWayLogging.Server.Services;

namespace WasmTwoWayLogging.Server.Logging
{
    public class LoggingHubTarget : AsyncTaskTarget
    {
        private readonly DebugService _debugService;
        public LoggingHubTarget(DebugService debugService)
        {
            _debugService = debugService;
            OptimizeBufferReuse = true;
        }
        protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken token)
        {
            string logMessage = this.Layout.Render(logEvent);
            await _debugService.LogToBrowser(logMessage);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// Server\Program.cs
using Microsoft.AspNetCore.ResponseCompression;
using WasmTwoWayLogging.Server.Hubs;
using WasmTwoWayLogging.Server.Logging;
using WasmTwoWayLogging.Server.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddSignalR();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddResponseCompression(opts =>
{
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" });
});
builder.Services.AddSingleton<DebugService>();

var app = builder.Build();

// Need to get reference to Server\Services\DebugService.cs to inject into Logging\Logger.cs::Configure
using (var serviceScope = app.Services.CreateScope())
{
    var services = serviceScope.ServiceProvider;

    DebugService debugService = services.GetRequiredService<DebugService>();
    Logger.Configure(debugService);
    Logger.Log.Info("App starting");
    // Lets log something every second to simulate legitimate log output
    System.Timers.Timer timer = new System.Timers.Timer(1000);
    timer.Elapsed += (source, e) =>
    {
        Logger.Log.Trace("tick");
    };
    timer.Enabled = true;
    timer.Start();
}

app.UseResponseCompression();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();

app.UseRouting();

app.MapRazorPages();
app.MapControllers();
app.MapHub<LoggingHub>("/hubs/logging");
app.MapFallbackToFile("index.html");

app.Run();
Enter fullscreen mode Exit fullscreen mode

Step 8: Set up NLog in the Client project

Time add the NLog to our client project.

cd .\Client
dotnet add package NLog
Enter fullscreen mode Exit fullscreen mode

Now lets quickly get NLog up and running and logging to the console.

@* Client\Pages\Counter.razor *@
@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        Logger.Log.Debug($"BEFORE: currentCount: {currentCount}");
        currentCount++;
        Logger.Log.Debug($"AFTER: currentCount: {currentCount}");
    }
}
Enter fullscreen mode Exit fullscreen mode
// Client\Logging\Logger.cs
using NLog;
using NLog.Config;
using NLog.Targets;

namespace WasmTwoWayLogging.Client.Logging
{
    public class Logger
    {
        public static NLog.Logger Log = LogManager.GetCurrentClassLogger();

        public static void Configure()
        {
            LoggingConfiguration config = new LoggingConfiguration();
            string layout = "${longdate}|Client|${level}|${callsite}|Line:${callsite-linenumber}|${message} ${all-event-properties} ${exception:format=tostring}";

            ConsoleTarget consoleTarget = new ConsoleTarget()
            {
                Layout = layout
            };
            config.AddRule(minLevel: NLog.LogLevel.Trace, maxLevel: NLog.LogLevel.Fatal, target: consoleTarget);

            LogManager.Configuration = config;
            Log = LogManager.GetCurrentClassLogger();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// Client\Program.cs
global using WasmTwoWayLogging.Client.Logging;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using WasmTwoWayLogging.Client;
using WasmTwoWayLogging.Client.Library;
using WasmTwoWayLogging.Client.Services;

Logger.Configure();
Logger.Log.Info("App starting.");

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<DebugService>();
builder.Services.AddScoped<JsConsole>();

await builder.Build().RunAsync();
Enter fullscreen mode Exit fullscreen mode

Now we have combined Server/Client logging via the JavaScript console:

Combined server/client logging via the JavaScript console

Step 9: Clean up Client code

First lets refactor Client\Services\DebugService.cs to only log server output to the console.

// Client\Services\DebugService.cs
using WasmTwoWayLogging.Client.Library;

namespace WasmTwoWayLogging.Client.Services
{
    public class DebugService
    {
        private readonly JsConsole _console;
        public DebugService(JsConsole console)
        {
            Logger.Log.Trace("Ctor");
            _console = console;
        }

        public async Task HandleLogFromServer(string logMessage)
        {
            await _console.LogAsync(logMessage);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Update MainLayout.razor to only mount the ServerConsole.razor component if our environment is 'Development'.

@* Client\Shared\MainLayout.razor *@
@inherits LayoutComponentBase
@inject DebugService DebugService
@using Microsoft.AspNetCore.Components.WebAssembly.Hosting
@inject IWebAssemblyHostEnvironment HostEnvironment

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
        @if (HostEnvironment.Environment == "Development")
        {
            <ServerConsole />
        }
    </main>
</div>
Enter fullscreen mode Exit fullscreen mode

And finally lets tell our SignalR connection to always retry if it drops out. We will do this by implementing IRetryPolicy. By default WithAutomaticReconnect will stop trying after four failed attempts. The default behaviour and how to implement custom retry policies can be read here.

// Client\Library\InfiniteRetryPolicy.cs
using Microsoft.AspNetCore.SignalR.Client;

namespace WasmTwoWayLogging.Client.Library
{
    public class InfiniteRetryPolicy : IRetryPolicy
    {
        public TimeSpan? NextRetryDelay(RetryContext retryContext)
        {
            // For the first 5 minutes try to reconnect every 15 seconds and after that try every 60
            if (retryContext.ElapsedTime < TimeSpan.FromSeconds(300))
            {
                return TimeSpan.FromSeconds(15);
            }
            else
            {
                return TimeSpan.FromSeconds(60);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
@* Client\Shared\ServerConsole.razor *@
@using Microsoft.AspNetCore.SignalR.Client
@using WasmTwoWayLogging.Client.Library
@inject DebugService DebugService
@inject NavigationManager NavigationManager
@implements IDisposable

<div class="server-console">Server Console: @_hubConnection?.State</div>

@code {
    private HubConnection? _hubConnection;

    private async Task OnReceiveLogEvent(string logMessage)
    {
        await DebugService.HandleLogFromServer(logMessage);
    }

    protected override async void OnInitialized()
    {
        _hubConnection = new HubConnectionBuilder()
            .WithUrl($"{NavigationManager.BaseUri}hubs/logging")
            .WithAutomaticReconnect(new InfiniteRetryPolicy())
            .Build();

        _hubConnection.On<string>("Log", OnReceiveLogEvent);
        await _hubConnection.StartAsync();
    }

    public void Dispose()
    {
        if (_hubConnection != null)
        {
            try
            {
                _hubConnection.StopAsync();
                _hubConnection.DisposeAsync();
                _hubConnection = null;
            }
            catch (Exception ex)
            {
                Logger.Log.Error(ex);
            }
            finally
            {
                _hubConnection = null;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 10/10: Connect client to server with custom NLog Target and finishing up

Here we will reimplement essentially the same LoggingHubTarget and LoggingHubConnection used in the last post, except this time in the Client project.

I wont cover every change in an embed as there are too many but you can browse the changes directly here.

Summary of changes:

  • Moved Client\Library\InfiniteRetryPolicy.cs to Shared\InfiniteRetryPolicy.cs as we will use on both Client and Server
  • Configure logs to stream to server via Client\Logging\Logger.cs, Client\Logging\LoggingHubConnection.cs and Client\Logging\LoggingHubTarget.cs
  • We needed some way to wire up Server\Services\DebugService.cs to listen for the "LogToServer" LoggingHub event and print them to console. Unfortunately this isn't possible directly from the HubContext so I used Microsoft.AspNetCore.SignalR.Client to set up a HubConnection within the DebugService. In summary: DebugService broadcasts logs from the server to the client with its HubContext and listens to broadcasts from the client with HubConnection.On.

And the end result can be seen below:

Unified client and server logs on the server and in the browsers console


Overall I'm happy with how easy it is to get this kind of logging functionality up and running in Blazor. I can see myself using it in my future projects.

Top comments (0)