RoushTech.Asio.SignalR 0.2.0

dotnet add package RoushTech.Asio.SignalR --version 0.2.0
                    
NuGet\Install-Package RoushTech.Asio.SignalR -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="RoushTech.Asio.SignalR" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RoushTech.Asio.SignalR" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="RoushTech.Asio.SignalR" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add RoushTech.Asio.SignalR --version 0.2.0
                    
#r "nuget: RoushTech.Asio.SignalR, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package RoushTech.Asio.SignalR@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=RoushTech.Asio.SignalR&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=RoushTech.Asio.SignalR&version=0.2.0
                    
Install as a Cake Tool

Asio

CI NuGet (RoushTech.Asio) NuGet (RoushTech.Asio.Redis) NuGet (RoushTech.Asio.SignalR) License: MIT

Real-time background job log streaming for ASP.NET Core.

Asio lets you stream log output from background jobs (Hangfire, hosted services, or any async workload) to connected clients in real-time, with full session replay for clients that connect after a job has already started. It hooks into the standard ILogger infrastructure — any Logger.Log* call made within a job context is automatically captured and forwarded to the session, with no changes to existing logging code.

Named after Asio, the genus of eared owls — watching quietly in the background.


Packages

Package Description
RoushTech.Asio Core: session tracking, ILoggerProvider, Channel<T> drain pipeline
RoushTech.Asio.Redis Redis-backed session log storage via StackExchange.Redis
RoushTech.Asio.SignalR Real-time delivery via ASP.NET Core SignalR

How It Works

  1. When a job starts, it calls JobSessionService.ActivateSession(sessionId), which sets an AsyncLocal<Guid?> on the current async context.
  2. A custom ILoggerProvider (JobSessionLoggerProvider) checks this AsyncLocal on every log call. If a session is active, it writes the entry to an in-process Channel<T>.
  3. JobSessionDrainService (a BackgroundService) reads from the channel and calls JobSessionService.AppendLog, which persists the entry via IJobSessionStore and pushes it live via IJobSessionSink.
  4. Clients connect to JobSessionHub and call Watch(sessionId). The hub replays all persisted log entries from the store, then keeps the client subscribed to live updates for the remainder of the job.

Because sessions are stored in Redis with a 24-hour TTL, clients can disconnect and reconnect at any time and receive the full log history.


Installation

dotnet add package RoushTech.Asio
dotnet add package RoushTech.Asio.Redis
dotnet add package RoushTech.Asio.SignalR

Setup

1. Register services

In Program.cs:

using RoushTech.Asio;
using RoushTech.Asio.Redis;
using RoushTech.Asio.SignalR;

builder.Services
    .AddAsio()
    .AddAsioRedis(builder.Configuration)  // reads ConnectionStrings:Redis
    // ...
    .AddSignalR()
    .AddAsioSignalR();

2. Map the hub

app.UseEndpoints(endpoints =>
{
    endpoints.MapAsioHub(); // default: /hubs/job-session
    // or with a custom path:
    endpoints.MapAsioHub("/hubs/my-jobs");
});

3. Configure Redis connection string

{
  "ConnectionStrings": {
    "Redis": "localhost:6379,password=yourpassword"
  }
}

4. Configure log levels

Asio respects standard ILogger configuration. To control what level Asio captures independently of other providers, use the "Asio" section:

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    },
    "Asio": {
      "LogLevel": {
        "Default": "Warning",
        "YourApp.Namespace": "Debug"
      }
    }
  }
}

5. Add Redis health check

builder.Services
    .AddHealthChecks()
    .AddAsioRedis(tags: ["critical"]);

Usage in a Background Job

Generate a session ID in your controller or wherever you enqueue the job, return it to the caller, and pass it into the job:

[Authorize]
[HttpPost("{id}/run")]
public async Task<IActionResult> TriggerJob(
    [FromRoute] Guid id,
    [FromServices] JobSessionService jobSessionService)
{
    var sessionId = Guid.NewGuid();

    // Record the owner so the default SessionOwnerAuthorizationFilter
    // can authorize the matching client later.
    await jobSessionService.CreateSession(sessionId, $"Job for {id}", ownerName: User.Identity?.Name);

    BackgroundJobClient.Enqueue<MyJobService>("queue", s => s.Run(id, sessionId));
    return Ok(new { sessionId });
}

In your job, activate the session at the start and complete it at the end:

public class MyJobService(
    ILogger<MyJobService> logger,
    JobSessionService jobSessionService)
{
    public async Task Run(Guid id, Guid sessionId)
    {
        JobSessionService.ActivateSession(sessionId);

        try
        {
            // All Logger calls below are automatically captured in the session.
            logger.LogInformation("Starting job for {Id}", id);

            await DoWork(id);

            logger.LogInformation("Job completed successfully.");
        }
        finally
        {
            await jobSessionService.CompleteSession(sessionId);
        }
    }
}

No other changes are needed — existing ILogger calls throughout the call tree are captured automatically for the duration of the activated session.


Frontend Integration

The hub exposes a small contract:

Direction Name Payload
Client → Server Watch(sessionId: string) Subscribes the caller. Replays all persisted log entries to the caller, then joins them to the live group for that session.
Server → Client LogMessage (message: string, level: number)level matches Microsoft.Extensions.Logging.LogLevel (Trace=0 … Critical=5).
Server → Client SessionComplete (hasError: boolean) — fired once when the job completes.

A minimal Vue 3 + @microsoft/signalr reference component lives at samples/vue/JobSessionLog.vue. It connects to the hub, replays history, renders live log lines with level-based coloring, and re-replays on reconnect. It's intentionally framework-light (no UI library dependency) — wrap it in your own dialog/modal as needed.

<JobSessionLog :session-id="sessionId" @complete="onJobComplete" />

Application-Wide Log Tail

Separate from the per-session job logs, Asio can expose a process-wide log tail — a circular buffer of everything written to ILogger, streamed live over SignalR. This is the equivalent of a "live logs" page: a new client gets the recent backlog immediately, then sees entries as they happen. It is independent of job sessions (it captures whether or not a session is active) and uses its own hub.

builder.Services
    .AddSignalR()
    .AddAsioSignalR()
    .AddAsioAppLogSignalR(options =>
    {
        options.RingCapacity = 1000;   // backlog replayed on connect (default 500)
        options.ChannelCapacity = 8000; // live buffer before oldest is dropped (default 4000)
    });
app.UseEndpoints(endpoints =>
{
    endpoints.MapAsioAppLogHub(); // default: /hubs/app-log, requires authorization
});

If you want the buffer without SignalR (e.g. to read AppLogBroadcaster.Snapshot() yourself), call services.AddAsioAppLog() from RoushTech.Asio directly.

Direction Name Payload
Server → Client AppLog AppLogEntry{ sequence, timestampUtc, level, category, message, exception }. Sent once per backlog entry on connect, then once per live entry.

The buffer never blocks the logging thread: writes go to a bounded channel that drops its oldest entry under back-pressure, and a background service drains it to clients. SignalR's own log categories are excluded by default (configurable via AppLogOptions.ExcludedCategoryPrefixes) so broadcasting can't feed itself.

Heads up: this stream exposes all application log output to any connected client, so MapAsioAppLogHub requires authorization by default. Only pass requireAuthorization: false if the endpoint is otherwise gated (e.g. network isolation).


Architecture Summary

Job (any async context)
  └─ Logger.LogInformation(...)
       └─ JobSessionLoggerProvider.IsEnabled() ── checks AsyncLocal session
            └─ ChannelWriter.TryWrite()          ── synchronous, non-blocking

JobSessionDrainService (BackgroundService, per host)
  └─ ChannelReader.ReadAllAsync()
       └─ JobSessionService.AppendLog()
            ├─ IJobSessionStore.AppendLog()      ── persists to Redis (24h TTL)
            └─ IJobSessionSink.PushLog()         ── pushes via SignalR

Client (browser)
  └─ HubConnection.invoke("Watch", sessionId)
       └─ JobSessionHub.Watch()
            ├─ replay all persisted logs to caller
            └─ subscribe to live updates via SignalR group

Security Considerations

Defaults are picked to be safe out of the box, but a few things are worth understanding before you deploy.

Authorization

  • MapAsioHub applies .RequireAuthorization() to the hub endpoint by default. Pass requireAuthorization: false only if you have an alternative gating mechanism (e.g. network isolation).

  • AddAsioSignalR() registers SessionOwnerAuthorizationFilter by default: a caller can Watch(sessionId) only if User.Identity?.Name matches the OwnerName recorded on the session. This means you must call JobSessionService.CreateSession(sessionId, label, ownerName: User.Identity?.Name) when starting a session — sessions without an owner are not watchable by anyone with the default filter active.

  • To add custom filters (e.g. role- or claim-based):

    builder.Services.AddSignalR().AddAsioSignalR(options =>
    {
        options.Authorization.Add<MyAdminAuthorizationFilter>();
    });
    

    All registered filters must pass for Watch to succeed. A denied call returns silently — the client receives no logs and no error, matching the behavior for an unknown session id (so existence of a session isn't leaked).

  • To disable the default owner filter, set options.UseDefaultOwnerAuthorization = false.

Session ids are sensitive

Treat session ids like capability tokens. They appear in URLs, logs, and browser history. Don't include them in error messages or analytics events that flow to third parties.

Log message content is untrusted

Logger.LogX calls typically include interpolated user input or external API responses. Asio forwards messages as plain strings — it does not sanitize them. When rendering log lines, always use a text-escaping mechanism (Vue's {{ }}, React's {}, etc.) — never v-html / innerHTML / dangerouslySetInnerHTML. The reference Vue sample escapes by default.

Resource limits

Asio does not bound per-session log volume (Redis list grows for the 24-hour TTL) or the in-memory channel depth. A misbehaving job that logs in a tight loop can pressure Redis and the host. Throttle at the source if you accept untrusted job code; Asio has no internal rate limiting in 0.1.x.


License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 205 6/19/2026
0.1.0 394 5/27/2026