Rask is instrumented for production operations with standard .NET primitives — no extra packages, OpenTelemetry-exportable out of the box. Five surfaces:
- Structured logging — framework faults flow into your
ILoggerpipeline. - Metrics — a
MeternamedRask.Serverwith session, handler, and frame counters/histograms, plus one meter per DB-backed pillar (Rask.Jobs,Rask.Outbox,Rask.Mail) carrying throughput, failures and dead letters. - Tracing — an
ActivitySourcenamedRask.Serverspanning handler dispatch. - Health checks — a live-session capacity check.
- Durable logs — opt-in:
Rask.Loggingkeeps theILoggerpipeline in a database of its own, so what happened survives the restart that hid it.
Everything is on by default with zero configuration; you opt in to exporting it.
Rask.Core and the WASM host take no dependency on Microsoft.Extensions.Logging — instead they
report diagnostics through an internal seam (RaskDiagnostics). On the server, UseRask<TApp>()
bridges that seam to your application's ILogger automatically. From then on every framework
diagnostic — a lifecycle hook that threw with no ancestor ErrorBoundary, a component Dispose
that faulted, a duplicate sibling Key, a malformed WebSocket frame, an event handler that threw —
is logged through an ILogger named for the diagnostic's category, at the matching level, with the
original exception attached:
| Category | Emitted for |
|---|---|
Rask.Lifecycle |
Faulting lifecycle hooks / disposes with no ancestor ErrorBoundary |
Rask.Diff |
Duplicate sibling Key (keyed reconciliation silently disabled) |
Rask.Live |
Handler / navigate / JS-invoke faults, malformed frames, coalesce-budget drops |
Rask.JsInvoke |
Failures surfacing a JS-invoke fault back to the caller |
Rask.HotReload |
Dev-time hot-reload failures: a registry refresh that threw, a repaint that faulted, or an undeliverable applied-notification |
No wiring is needed — configure log levels for these categories like any other:
When no ILoggerFactory is registered (e.g. a bare test host), the seam keeps its default behaviour
and writes the same diagnostics to stderr.
Everything above is a transport: the diagnostics reach whatever sinks you configured, which on a
single-box deployment usually means the container's stdout — gone with the next restart. Add
Rask.Logging to keep them:
builder.Services.AddRaskLogging(
builder.Configuration.GetConnectionString("Logs") ?? "Data Source=logs.db");It registers an ILoggerProvider, so it captures exactly what every other sink sees — the categories above
included — into a SQLite file of its own, with retention by age and row count and a searchable view in the
dashboard. Its rask.logs.dropped counter is the one metric that tells you the stored log is
incomplete.
All metrics publish on the meter named Rask.Server (RaskTelemetry.MeterName).
| Instrument | Kind | Tags | Meaning |
|---|---|---|---|
rask.sessions.created |
Counter | Live sessions created (component tree + DI scope). | |
rask.sessions.rejected |
Counter | Creations refused because MaxSessions was reached. |
|
rask.sessions.evicted |
Counter | Sessions removed (disconnect grace elapsed / shutdown). | |
rask.sessions.active |
Gauge | Live sessions currently held. | |
rask.handlers.dispatched |
Counter | Client event handlers dispatched to user code. | |
rask.handlers.faulted |
Counter | Handler dispatches that threw (isolated; session survives). | |
rask.handlers.timedout |
Counter | Handler dispatches cancelled by HandlerTimeout. |
|
rask.handler.duration |
Histogram (ms) | Wall-clock duration of an event-handler dispatch. | |
rask.ws.frames.rejected |
Counter | reason = size | rate | backlog | idle |
Inbound frames refused by a safety limit. |
rask.sessions.resumed |
Counter | Pages rebuilt on a host that had never heard of the session, from the client's resume record. | |
rask.sessions.resume_rejected |
Counter | reason = malformed | unprotect | principal | toolarge | atcapacity |
Resume records refused. |
rask.shutdown.sessions.abandoned |
Counter | Sessions still connected when the shutdown drain budget ran out; their sockets were aborted. | |
rask.sessions.connected |
Gauge | Sessions with a socket attached — people actually looking at the app, as opposed to active, which also counts GET-minted sessions whose socket never arrived and sessions riding out their reconnect grace. |
|
rask.handlers.pending |
Gauge | Handler dispatches queued across all sessions. The backpressure breaker's input — frames.rejected{reason=backlog} is its output. |
|
rask.render.duration |
Histogram (ms) | Time to render a session and write its frame. The framework's half of a slow interaction; handler.duration is your half. |
|
rask.payload.bytes |
Histogram (By) | Size of a frame sent to a client. Watch the distribution: a page that quietly stops diffing jumps to its full size here long before anyone notices the bandwidth. |
A nonzero rask.shutdown.sessions.abandoned is the signal that ShutdownDrainTimeout — or the
HostOptions.ShutdownTimeout containing it — is shorter than your app's real shutdown, so some users saw
an abnormal disconnect instead of a clean "Updating…" reload. Because it is emitted during shutdown, its
delivery depends on your exporter's final flush; the same fact is logged, which is the more reliable read.
The rask.ws.frames.rejected counter is the headline DoS-visibility signal: a spike on
reason=rate or reason=backlog means a client is being throttled by the per-connection frame-rate
cap or the pending-handler backpressure breaker. reason=idle is the IdleSocketTimeout reclaiming a
silently-idle connection, not an attack.
rask.sessions.resume_rejected is worth an alert of its own. A steady trickle is normal — expired records
from laptops that slept. A spike on reason=unprotect immediately after a deploy means your
data-protection key ring is not surviving the deploy, so every user is getting a reload instead of their
page back (and, if you use cookie auth, being signed out). See
surviving a restart.
Each DB-backed pillar publishes on its own meter, registered by its AddRaskX call. The shape is the same
for all three — only the noun changes.
| Instrument | Kind | Tags | Meaning |
|---|---|---|---|
rask.jobs.processed |
Counter | job.type |
Jobs that ran to completion. |
rask.jobs.failed |
Counter | job.type |
Attempts that threw — every attempt, not every job. |
rask.jobs.deadlettered |
Counter | job.type |
Jobs that exhausted MaxAttempts, counted once. |
rask.jobs.interrupted |
Counter | job.type |
Cancelled by shutdown past ShutdownGracePeriod. Re-runs on restart; counts no attempt. |
rask.jobs.duration |
Histogram (ms) | job.type |
Wall-clock duration of one execution. |
rask.jobs.pending |
Gauge | Not yet processed and not yet exhausted. | |
rask.jobs.deadletters |
Gauge | The number worth alerting on. |
Rask.Outbox publishes the same seven as rask.outbox.* tagged by message.type; Rask.Mail publishes
rask.mail.sent / failed / deadlettered / interrupted / duration / pending / deadletters.
rask.*.interrupted says your shutdown budget is too short. It counts work that a SIGTERM cancelled
after its ShutdownGracePeriod — which re-runs from the top on the next boot, so any non-idempotent handler
is repeating its side effects. For rask.mail.interrupted it is sharper than that: because a cancelled SMTP
conversation can be accepted by the server before the row is marked, it is the direct answer to "did that
deploy duplicate any mail?" See Shutdown and redeploy.
rask.*.deadletters is the alert. Delivery is at-least-once with backoff, so a pillar retrying itself
to death still shows a healthy processed rate — the dead-letter gauge is the one that says work has been
abandoned. Pair it with the dashboard, which shows which rows and why.
Why mail has no type tag. Jobs and the outbox tag by their registered type, a closed set fixed at build time by a source generator. Mail's only per-message dimensions are subject and recipient, both unbounded — tagging by either would mint a time series per email sent.
The queue-depth gauges cost nothing until you subscribe. They are sampled by the processor's existing poll and only while a listener is attached, rather than running
COUNT(*)inside the observable-gauge callback — which would otherwise put read load on the app's database on the collector's schedule.
dotnet-counters monitor --counters Rask.Server,Rask.Jobs,Rask.Outbox,Rask.Mail --process-id <pid>builder.Services.AddOpenTelemetry()
.WithMetrics(m => m
.AddMeter(RaskTelemetry.MeterName)
.AddMeter(JobMetrics.MeterName)
.AddMeter(OutboxMetrics.MeterName)
.AddMeter(MailMetrics.MeterName))
.WithTracing(t => t.AddSource(RaskTelemetry.ActivitySourceName));Each event-handler dispatch starts an Activity named rask.handler.dispatch on the
Rask.Server ActivitySource, tagged with rask.handler.id and marked Error (with the exception
message) when the handler throws. With no listener registered the span is never materialised, so the
instrumentation costs nothing. Subscribe with .AddSource(RaskTelemetry.ActivitySourceName) as
above (or any ActivityListener).
AddRaskLiveSessions() registers two checks, because they answer different questions: capacity
(RaskLiveHealthCheck, tagged live) and readiness (RaskReadinessHealthCheck, tagged ready).
Register them on your health-checks pipeline:
builder.Services.AddRask(o => o.MaxSessions = 1000);
builder.Services.AddHealthChecks().AddRaskLiveSessions();
// ...
app.MapHealthChecks("/health");
app.UseRask<App>();| Status | Condition |
|---|---|
Healthy |
Below 80% of MaxSessions (or uncapped), and memory below 80% of the limit. |
Degraded |
At or above 80% of MaxSessions, or memory at 80% of the limit. |
Unhealthy |
At MaxSessions (new sessions are being refused with 503), or memory at 92%. |
Memory is checked as well as the session count, and outranks it. A cap alone can't keep a host
healthy, because what a session costs is a property of the page rather than of the user: the same host
holds ~66,000 sessions of a trivial page or ~735 of a 200-row grid. A cap sized for the small page is no
protection on the big one. The reading comes from GCMemoryInfo, which honours a container memory limit,
so it reflects the ceiling a deployed app actually runs under — and an uncapped host, which is what most
apps run, now reports something before an OOM does. A memory position the runtime won't disclose is
treated as healthy, not as full: a host must not shed load because it can't measure itself.
activeSessions, connectedSessions, maxSessions and memoryLoad are attached to the health-check
result data for dashboards. Pair the capacity cap with a reverse-proxy rate limit for precise admission
control.
RaskReadinessHealthCheck answers one question: is this instance still accepting live sessions? It is
Healthy normally and Unhealthy from the moment a graceful shutdown begins, so an aggregate /health
returns 503 while draining and a load balancer with active probes stops routing here.
It is kept separate from the capacity check on purpose — that one's Unhealthy already means "at
MaxSessions, refusing with 503", and folding a second cause into it would make both readings
ambiguous exactly when someone is diagnosing an incident. Split them with the tags when you want distinct
probes:
app.MapHealthChecks("/health/ready",
new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.MapHealthChecks("/health/live",
new HealthCheckOptions { Predicate = c => c.Tags.Contains("live") });