Blazor to Serilog in ASP.NET Core

Microsoft’s new UI stack, Blazor, features in just about every summary of what’s new in .NET Core 3. I’m not planning to use “server-side” Blazor, although it’s undeniably impressive (frameworks based on stateful client/server sessions tend to blow their complexity budget dealing with all the messy realities of deployment on the web). “Client-side” Blazor, which runs .NET code in the browser using WebAssembly, is more interesting to me.

I’ve made my peace with JavaScript, and I’m happy in the polyglot web development world we mostly inhabit, but there are still times I’d like to share C# modules between the browser and server. In Seq, for example, we have a substantial C# query parser and expression evaluator that we’d love to be able to use client-side.

Since WASM is here to stay, I’m keeping an eye on client-side Blazor, and tinkering with a few ideas…

Spiking out some core Serilog + Blazor scenarios

There are two interesting scenarios I can see for Serilog in Blazor.

The first is interaction with the browser console, which natively supports structured data in the form of JavaScript objects. For a WASM module to log structured data through the browser console, serialization or mashalling is required, and this isn’t something that can or should be done for every argument to every log call. Serilog recognizes this distinction, and its {@Property} capturing syntax triggers serialization only when it’s desirable; the ToString() representation of objects is sent to the browser console otherwise.

var user = new {Name = "nblumhardt", Id = 42};
Log.Information("User {@User} logged in", user);

In Firefox this comes out rather nicely:

I won’t say much more about this feature, here, but you can dotnet add package Serilog.Sinks.BrowserConsole and WriteTo.BrowserConsole() to see this in action for yourself, and check out the code on GitHub.

The second interesting scenario is a natural extension of Blazor’s essential value proposition: transparently send client-side log events through the server-side log stream, for easy collection and analysis.

A quick spike, which I’ll write about below, suggests it’s a compelling and rather pleasant developer experience that’s worth more investigation.

Serilog.Sinks.BrowserHttp and Serilog.AspNetCore.Ingestion

Serilog.Sinks.BrowserHttp is a client-side sink that POSTs newline-delimited, JSON-encoded log events from the browser to an HTTP endpoint. This could be implemented by the app’s origin server, by another web application entirely, or by a log server that can ingest HTTP payloads.

Serilog.AspNetCore.Ingestion implements a matching HTTP endpoint as middleware that can be plugged into any ASP.NET Core app running Serilog. Events received by the middleware are deserialized into Serilog LogEvents, and written to the server’s log pipeline. Events from the client are tagged with a property Origin = 'Client' so that they can be robustly identified.

The two packages can be used separately, but work nicely together. Here you can see a client-side button click event, running in Blazor, being logged directly to the server-side terminal, and a client-side error following immediately after:

Having only one log stream to watch is nice, even at development time.

Creating the demo solution

The easiest way to set up a Blazor client app with an ASP.NET Core server is using the blazorwasm template, installed along via the .NET Core 3.1 preview SDK.

dotnet new blazorwasm --hosted

This creates a solution file and three projects: ClientServer, and Shared. The Server project is a web app that hosts the Client. Check that everything is set up correctly using dotnet run.

Setting up the Blazor client

In the Client project, install Serilog.Sinks.BrowserHttp. I’m going to enable Serilog.Sinks.BrowserConsole in the example, so I’ll install that package, too.

cd ./Client
dotnet add package Serilog.Sinks.BrowserHttp
dotnet add package Serilog.Sinks.BrowserConsole

Blazor apps start in Program.cs; here’s a complete listing that shows how Serilog is configured:

using System;
using Microsoft.AspNetCore.Blazor.Hosting;
using Serilog;
using Serilog.Core;

public class Program
{
    public static void Main(string[] args)
    {
        var levelSwitch = new LoggingLevelSwitch();
        Log.Logger = new LoggerConfiguration()
            .MinimumLevel.ControlledBy(levelSwitch)
            .Enrich.WithProperty("InstanceId", Guid.NewGuid().ToString("n"))
            .WriteTo.BrowserHttp(controlLevelSwitch: levelSwitch)
            .WriteTo.BrowserConsole()
            .CreateLogger();

        Log.Information("Hello, browser!");

        try
        {
            CreateHostBuilder(args).Build().Run();
        }
        catch (Exception ex)
        {
            Log.Fatal(ex, "An exception occurred while creating the WASM host");
            throw;
        }
    }
    
    public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
        BlazorWebAssemblyHost.CreateDefaultBuilder()
            .UseBlazorStartup<Startup>();
}

You’ll note that WriteTo.BrowserHttp() doesn’t require a URL to be specified; it will default to /ingest on the origin server.

LoggingLevelSwitch is created, specified as controlling the minimum level of the Serilog pipeline, and passed through to WriteTo.BrowserHttp(). This allows the server to set the client’s log level, avoiding wasted bandwidth. I think there’s more to explore in this part of the integration (for example, using LoggingFilterSwitch from Serilog.Filters.Expressions, and configuring more fine-grained client log management from the server side).

You’ll also spot Enrich.WithProperty("InstanceId", Guid.NewGuid().ToString("n")), which makes it easy to zero in on logs from a single running instance of the Blazor app (i.e. a single browser tab) by tagging them with a unique identifier.

Note that there’s no finally { Log.CloseAndFlush() } at the end of Main(). This is because IWebAssemblyHost.Run() is non-blocking, and so Main() returns immediately.

Adding some client-side logging

The sample template includes a page called Counter.razor. It’s a nice place to add a simple logging statement so that some more logs can be triggered:

@page "/counter"
@using Serilog

<h1>Counter</h1>

<p>Current count: @currentCount</p>

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

@code {
    private int currentCount = 0;
    private static readonly ILogger Log = Serilog.Log.ForContext<Counter>();

    private void IncrementCount()
    {
        currentCount++;
        Log.Information("Incremented count to {CurrentCount}", currentCount);
    }
}

Log.Information() won’t block the UI while the event is sent to the server: WriteTo.BrowserHttp() uses asynchronous, batched requests, to keep resource usage to a minimum.

It’s C#, JIT-compiled via WASM, and running in a browser….. but it’s still the Serilog we know and love.

Enabling ingestion on the ASP.NET Core server

Before enabling ingestion on the server side, you’ll need to set up Serilog. I’ll assume that, just like the example in that article, you’ll initialize Log.Logger, and call WriteTo.Console().

Next, to ingest logs from the Blazor client, add the Serilog.AspNetCore.Ingestion package:

cd ./Server
dotnet add package Serilog.AspNetCore.Ingestion

If you followed all of the instructions in the ASP.NET Core setup article, in Startup.cs, you’ll have:

app.UseSerilogRequestLogging();

The ingestion middleware should probably come before request logging, so that POSTed log payloads from the client don’t trigger too much log noise. Add one more call, app.UseSerilogIngestion(), just above it, so your middleware pipelin has:

app.UseSerilogIngestion(); // <-- Add this line
app.UseSerilogRequestLogging();

And that’s everything. If you run the Server project, you can open the Counter page in a web browser and click a few times.

The logs from the client will show up alongside the other events from the ASP.NET Core app. There isn’t much to distinguish client from server logs in this default setup; you’ll need to switch to JSON output, or pipe logs off to a log server (like Seq) to see the attached properties such as InstanceId and Origin.

If things don’t work as expected, calling Serilog.Debugging.SelfLog.Enable(m => Console.Error.WriteLine(m)) when the Blazor app starts up should get some debugging information into the browser console.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *