RoushTech.Asio.Hangfire
0.3.0
dotnet add package RoushTech.Asio.Hangfire --version 0.3.0
NuGet\Install-Package RoushTech.Asio.Hangfire -Version 0.3.0
<PackageReference Include="RoushTech.Asio.Hangfire" Version="0.3.0" />
<PackageVersion Include="RoushTech.Asio.Hangfire" Version="0.3.0" />
<PackageReference Include="RoushTech.Asio.Hangfire" />
paket add RoushTech.Asio.Hangfire --version 0.3.0
#r "nuget: RoushTech.Asio.Hangfire, 0.3.0"
#:package RoushTech.Asio.Hangfire@0.3.0
#addin nuget:?package=RoushTech.Asio.Hangfire&version=0.3.0
#tool nuget:?package=RoushTech.Asio.Hangfire&version=0.3.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, app-log broadcaster |
RoushTech.Asio.Redis |
Redis-backed session log storage via StackExchange.Redis — multi-host, 24-hour TTL |
RoushTech.Asio.InMemory |
In-memory session storage — single-host, ephemeral, bounded ring buffer per session with LRU eviction |
RoushTech.Asio.SignalR |
Real-time delivery via ASP.NET Core SignalR (server-side hub for browser clients) |
RoushTech.Asio.SignalRClient |
Satellite → host log forwarding over an authenticated HubConnection (client-side of a multi-node deployment) |
RoushTech.Asio.Hangfire |
IServerFilter that wraps every Hangfire job in an Asio session automatically |
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.
Sessions are stored by an IJobSessionStore implementation — pick Redis (RoushTech.Asio.Redis, multi-host, 24-hour TTL) or in-memory (RoushTech.Asio.InMemory, single-host, ephemeral). Clients can disconnect and reconnect at any time and receive the full log history retained by the chosen store.
Installation
dotnet add package RoushTech.Asio
dotnet add package RoushTech.Asio.SignalR
# Pick one storage backend:
dotnet add package RoushTech.Asio.Redis # multi-host, 24h TTL
# — or —
dotnet add package RoushTech.Asio.InMemory # single-host, ephemeral
# Optional:
dotnet add package RoushTech.Asio.Hangfire # auto-open a session per Hangfire job
dotnet add package RoushTech.Asio.SignalRClient # forward sessions to a central host (satellite deployment)
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();
Or, for single-host deployments where sessions can be ephemeral:
using RoushTech.Asio;
using RoushTech.Asio.InMemory;
using RoushTech.Asio.SignalR;
builder.Services
.AddAsio()
.AddAsioInMemory(o =>
{
o.PerSessionLogCapacity = 500; // ring buffer size per session (default 500)
o.MaxSessions = 200; // oldest session evicted past this (default 200)
})
.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.
Automatic sessions for Hangfire jobs
If you use Hangfire, RoushTech.Asio.Hangfire skips the manual ActivateSession/CompleteSession dance by wrapping every job in an IServerFilter:
builder.Services.AddAsioHangfire(o =>
{
o.OwnerId = "core"; // sessions are created with this ownerName + indexed in ISessionRegistry
o.LabelPrefix = "Core: "; // label becomes "Core: MyJobService.Run"
});
// After the host is built, before the Hangfire server starts:
app.Services.UseAsioJobSessionFilter();
Every Hangfire job now runs inside its own Asio session automatically; Logger.Log* calls anywhere in the call tree are captured for the duration.
Multi-node deployments (satellite → host)
RoushTech.Asio.SignalRClient lets a satellite process (a worker, collector, node — anything that runs jobs but doesn't serve browsers) forward its sessions and full app-log tail to a central Asio host over an already-authenticated HubConnection:
builder.Services
.AddAsio()
.AddAsioHub(o =>
{
// Optional: override the hub method names the host exposes for ingestion.
// o.StartMethod = "StartLogSession";
// o.AppendMethod = "AppendLogLines";
// ...
})
.AddAsioHubAppLog(); // also forward this process's whole-process log tail
// You supply the connection lifecycle:
builder.Services.AddSingleton<IAsioHubConnectionSource, MyHubConnectionSource>();
The satellite runs the standard Asio pipeline, but its IJobSessionStore becomes a HubJobSessionStore that enqueues ops on a bounded (drop-oldest) channel; AsioHubFlushService drains that channel and pushes over the connection, coalescing consecutive same-session appends into one SignalR frame. Sends are best-effort — frames drop when the connection is down.
The host must expose hub methods matching the configured names (StartLogSession, AppendLogLines, CompleteLogSession, AppLogTail) and receive the shapes defined by AsioSessionStartPayload, AsioLogBatchPayload, AsioSessionCompletePayload, and AsioAppLogBatch in the SignalRClient package. SignalR binds by property name, so the host's DTOs must match names exactly.
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
- Hangfire.Core (>= 1.8.14)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- RoushTech.Asio (>= 0.3.0)
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.3.0 | 51 | 7/14/2026 |