Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

Rask.Logging — a durable log store on your database

In practice: the dashboard's Logs page reads it · observability · deployment.

Rask.Logging keeps the application's log where you can query it — in a SQLite file of its own, or, on PostgreSQL or SQL Server, in the application's database — with no agent and no hosted log service. It registers a standard ILoggerProvider, so it captures exactly what every other sink sees, buffers entries through a bounded channel that never blocks the caller, and writes them in batches on a background service. Retention is enforced by age and by row count.

Included in Rask.Server — nothing to install. It is on; an app that does without it says so:

app.Configure(c => c.Logs.Off());

Why a log store at all

The failures that matter most leave no row in any table. Litestream exiting, a job type that won't deserialize, a handler that threw on the one request that mattered — those are log lines, and on a single box they live in a container's stdout that the next restart takes with it. stdout is a fine transport and a terrible archive.

The dashboard has always had a live tail, but it says so plainly: it is bounded by count and gone on restart. This is the other half — the log you can still read tomorrow, and search.

Use

// Program.cs
builder.Services.AddRaskLogging();
// appsettings.json
{
  "Rask": {
    "ConnectionStrings": {
      "Logs": "Data Source=logs.db"
    }
  }
}

That is the whole setup. The store opens Rask:ConnectionStrings:Logs, and a missing one is an error naming the key to set. The schema is created on first use, so there is no migration to add — unlike the other database-backed pillars, this one doesn't touch your DbContext.

rask new MyApp scaffolds exactly the lines above — the log store is on by default (--no-logs leaves it out).

On PostgreSQL or SQL Server

// Program.cs
builder.Services.AddRaskLogging<AppDbContext>();

// AppDbContext.OnModelCreating
modelBuilder.AddRaskLogging();
rask db add AddLogs && rask db update

On a server database the log goes into the application's own database, as a RaskLog table your migrations create. The reasons for a file of its own (below) weigh differently there:

  • Write frequency. PostgreSQL and SQL Server lock rows, not the whole database, so a machine-rate writer does not queue your requests behind it.
  • Transaction entanglement. Every flush runs on a context — and a connection — of its own, so a line logged while one of your transactions is failing commits on its own and survives the rollback.

In return the log is covered by the database's own backups, and several instances of the app share one log; each runs its writer, and their retention sweeps are safe to run at the same time.

Everything else on this page is the same — options, scopes, queries, the dashboard, metrics. Pragmas and BusyRetry belong to the file store and do nothing here. What EF Core logs on the store's own behalf is never captured back into it, while your application's SQL still is, so keeping the noise down still applies. A model that never mapped the table fails the boot with the line to add. There is no Rask:ConnectionStrings:Logs to set: the store connects through your context.

On SQLite, keep the file store: there every writer shares one lock, and a file of its own is the point.

An app built with RaskApp gets this without writing it: when Rask:Database:Provider is postgres or sqlserver, the log store moves into the application database and RaskAppDbContext maps the table. On SQLite it stays in logs.db. A context of the app's own maps the table itself with modelBuilder.AddRaskLogging(), and is told the line at start if it does not; an app that calls AddRaskLogging() itself keeps its file store. See choosing the database.

Options

Every option is Rask:Logs in appsettings.json:

{
  "Rask": {
    "Logging": {
      "MinimumLevel": "Warning",        // a floor, not an override — see below
      "Retention": "30.00:00:00",       // "00:00:00" keeps entries forever
      "MaxRows": 250000,                // 0 removes the cap
      "FlushInterval": "00:00:01",
      "BatchSize": 500,
      "QueueCapacity": 10000,
      "ExcludedCategories": [ "Microsoft.AspNetCore." ],

      "CaptureScopes": true,            // store ambient ILogger.BeginScope state (default)
      "MaxScopeValues": 16,             // per entry
      "MaxScopeValueLength": 256        // per value
    }
  }
}

ExcludedCategories is appended to rather than replaced. A callback — AddRaskLogging(o => …) — runs after the section and wins.

Scopes

Whatever an ILogger.BeginScope was opened with is stored alongside the entry, so the log answers "what else happened on that request?" instead of leaving it to be reconstructed from message text:

using (logger.BeginScope("request {RequestId} for {UserId}", requestId, user.Id))
{
    logger.LogInformation("charging {Amount}", amount);   // stored with RequestId and UserId
}

Nested scopes are flattened outermost-first; a scope opened with a bare value (BeginScope("checkout")) is stored under the key Scope. The message template itself is not stored — only the values it formats.

The cost sits where it has to: flattening happens at the log call, because scope state is short-lived and may be reused the moment the scope closes. Encoding it to JSON does not — the writer does that on its own thread, so the request path keeps the "a log call never waits on the disk" property. Both bounds above exist so a runaway loop of nested scopes, or one large object's ToString(), cannot grow a row without limit.

Turn CaptureScopes off if your scopes carry values you would rather not have at rest. Note the same caveat as the rest of the store: log lines can contain secrets — treat the file as sensitive.

Keeping the noise down

An EF Core app logs every SQL command at Information, so on the defaults that is most of what the store will contain. Two levers, and the first is usually the right one:

// appsettings.json — filters before the store ever sees the entry
"Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }
// …or skip a category for this sink only, leaving your console output alone
"Rask": { "Logging": { "ExcludedCategories": [ "Microsoft.EntityFrameworkCore.Database" ] } }

What is already quiet: Rask's own. The batteries poll — jobs, mail and the outbox every five seconds each, plus the cache purge, the orphan scan and the session sweep — and none of that bookkeeping is a thing you asked to read. It runs on a context that logs its SQL at Debug, along with the log store's own INSERT, so what these two levers decide is the volume of your queries, not the framework's heartbeat. To watch a battery claim its batch, turn the same category up:

"Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Debug" } }

An app that registers a hand-written IDbContextFactory<T> keeps it — Rask will not copy a factory whose job is to pick a connection — so there the batteries log at Information like everything else.

MinimumLevel is a floor, not an override. The logging pipeline applies your Logging:LogLevel configuration first, so an entry filtered there never reaches the store however low you set this. If the store looks emptier than you expect, check appsettings.Production.json before you check this.

Reading it back

public sealed partial class IncidentPage : Component
{
    // … nothing injected: Logs reaches the store of the work this render runs in
    var page = await Logs.Search(new LogQuery
    {
        MinimumLevel = LogLevel.Error,
        Category = "Shop.Checkout",
        Search = "declined",
        ScopeKey = "RequestId",       // entries captured under this scope key…
        ScopeValue = "0HN7…",         // …with this value. Key alone finds every entry that carried it.
        From = DateTimeOffset.UtcNow.AddHours(-6),
        Page = 1,
        PageSize = 50,
    });
}

Each LogRecord carries its Scopes as key/value pairs. The scope filter matches the stored key and value exactly rather than searching the row as text: it looks for the JSON-encoded "key":"value" pair, so a request id cannot match an entry that merely mentioned it in a message, and r2 does not match r22. Keys and values may hold spaces, quotes or accents. On SQL Server the comparison follows the database's collation, which ignores case by default.

LogPage carries the matching entries (newest first), the TotalCount behind the filter, and a PageCount. Logs also offers Categories() for a filter dropdown, Count(), Clear(), and retention as steps:

await Logs.Trim().OlderThan(30.Days);                       // by age
await Logs.Trim().KeepingNewest(100_000);                   // by count
await Logs.Trim().OlderThan(30.Days).KeepingNewest(100_000); // both, in one pass

A Trim() with neither step removes nothing, and each step refuses a value that would empty the store — Logs.Clear() says that out loud. Inject ILogs where there is no work in progress to reach it through, and it words the same sentences.

How it works

Step What happens
A log call ILogger → the registered provider → a bounded in-memory channel. Never blocks, never throws.
The buffer fills The entry is dropped and counted on rask.logs.dropped.
Every FlushInterval A background writer drains the channel and inserts up to BatchSize rows per transaction.
Shutdown The writer drains what's buffered, bounded by ShutdownGracePeriod (5s).
Every SweepInterval Entries older than Retention go, then the store is trimmed to the newest MaxRows — deleted in pages of 1,000 so the write lock is never held for a whole sweep.

Dropping is the design, not a bug. A log call happens on whatever thread is serving a request, and the one thing it must never do is wait for a disk write. Unbounded buffering would trade a visible drop count for an invisible memory leak under exactly the log storm that causes it. A batch lost to a failed write is counted the same way, so rask.logs.dropped stays the single honest answer to "is the log I'm reading complete?"

Its own file, on purpose

Every other database-backed pillar (AddRaskJobs<TContext>, AddRaskOutbox<TContext>, …) maps an entity onto your DbContext and rides your migrations. On SQLite this one deliberately doesn't (on a server database it does — see above):

  • Write frequency. Jobs and emails arrive at human rates; log lines arrive at machine rates. Routing them through your DbContext would put a high-frequency writer on the same single SQLite write lock your request path and processors already contend for.
  • Transaction entanglement. The most valuable log line is the one written while a transaction is failing. On your context that line rolls back with the failure — the store would lose exactly what it exists to keep.
  • Migration churn. A framework-owned append-only table has no business in your migration history.

The trade-off is real and worth stating: logs.db is not covered by rask db backup or Litestream. That is deliberate — logs are expendable and high-churn, and keeping them out of app.db keeps snapshots and WAL replication cheap. If you need the log archived, copy the file like any other artifact.

Log lines contain secrets. This feature writes them to a file that outlives the process. Treat logs.db as sensitive: keep it off public volumes, and remember that anyone who can read it can read whatever your application logged.

In the dashboard

With Rask.Dashboard installed, /_rask/logs gains a History mode beside the existing Live tail: paged, filterable by level and category, with a substring search over the message and the exception. Live stays the default and still reads nothing from disk.

The search is served by an index rather than a scan. Logs are searched for fragments — an order id, a path, half an exception type — not words, so the store keeps an FTS5 table with the trigram tokenizer beside the log, kept current by triggers as entries arrive and as retention removes them; a search of three or more characters is a case-insensitive substring match looked up in it, and a shorter one falls back to the scan. A logs.db written by an earlier version is indexed once, the first time it is opened.

Two modes rather than one merged view because the writer flushes on an interval — the newest lines are in the buffer but not yet on disk, and a merged view would quietly disagree with itself for a second at a time.

Metrics

On the Rask.Logging meter (LogMetrics.MeterName):

Instrument Meaning
rask.logs.written Entries persisted.
rask.logs.dropped Entries that never reached the store — a full buffer, or a batch whose write failed. The number worth alerting on.
rask.logs.purged Entries removed by retention.
rask.logs.stored Entries currently held. Sampled by the writer's sweep, not on every observation.
dotnet-counters monitor --counters Rask.Logging

Deploying

rask deploy sets Rask__ConnectionStrings__Logs=Data Source=/data/logs.db, on the same named volume as the application database — without it the log would land in the container's writable layer and be destroyed by the very restart it exists to survive.

Notes

  • One writer per logs.db. SQLite is single-writer; run one process against a given file, exactly as with the jobs and outbox processors. On a server database, every instance of the app writes to the shared table.
  • The drivers are never captured. Microsoft.Data.Sqlite, Npgsql and Microsoft.Data.SqlClient log from inside the store's own writes, so they are excluded whatever ExcludedCategories says.
  • No reflection of its own. Scope state is encoded with a source-generated JsonSerializerContext, not the reflection serializer, so the file store is free of IL2026/IL3050. The application-database store runs EF Core LINQ queries and is exactly as trim- and AOT-ready as EF Core itself — which for a server app is not a concern. The package depends on Microsoft.Data.Sqlite, EF Core and the logging/hosting abstractions.
  • The schema upgrades itself. The Scopes column is added to a store created by an earlier version on first use — this database is framework-owned and deliberately outside your migration history, so there is nothing for you to run.

See also

  • Dashboard — the Logs page that reads this store.
  • Observability — logging categories, meters, tracing, health checks.
  • SQLite — the production pragmas this store applies to its own file.

Confirm