RoushTech.Asio.Redis
0.2.0
dotnet add package RoushTech.Asio.Redis --version 0.2.0
NuGet\Install-Package RoushTech.Asio.Redis -Version 0.2.0
<PackageReference Include="RoushTech.Asio.Redis" Version="0.2.0" />
<PackageVersion Include="RoushTech.Asio.Redis" Version="0.2.0" />
<PackageReference Include="RoushTech.Asio.Redis" />
paket add RoushTech.Asio.Redis --version 0.2.0
#r "nuget: RoushTech.Asio.Redis, 0.2.0"
#:package RoushTech.Asio.Redis@0.2.0
#addin nuget:?package=RoushTech.Asio.Redis&version=0.2.0
#tool nuget:?package=RoushTech.Asio.Redis&version=0.2.0
Asio
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
- When a job starts, it calls
JobSessionService.ActivateSession(sessionId), which sets anAsyncLocal<Guid?>on the current async context. - A custom
ILoggerProvider(JobSessionLoggerProvider) checks thisAsyncLocalon every log call. If a session is active, it writes the entry to an in-processChannel<T>. JobSessionDrainService(aBackgroundService) reads from the channel and callsJobSessionService.AppendLog, which persists the entry viaIJobSessionStoreand pushes it live viaIJobSessionSink.- Clients connect to
JobSessionHuband callWatch(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
MapAsioAppLogHubrequires authorization by default. Only passrequireAuthorization: falseif 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
MapAsioHubapplies.RequireAuthorization()to the hub endpoint by default. PassrequireAuthorization: falseonly if you have an alternative gating mechanism (e.g. network isolation).AddAsioSignalR()registersSessionOwnerAuthorizationFilterby default: a caller canWatch(sessionId)only ifUser.Identity?.Namematches theOwnerNamerecorded on the session. This means you must callJobSessionService.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
Watchto 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 | Versions 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. |
-
net9.0
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 9.0.0)
- RoushTech.Asio (>= 0.2.0)
- StackExchange.Redis (>= 2.8.16)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.