# Rask.Logging — a durable log store on your database

> **In practice:** the [dashboard](https://rask.sh/docs/guides/dashboard/)'s Logs page reads it · [observability](https://rask.sh/docs/guides/observability/) ·
> [deployment](https://rask.sh/docs/guides/deployment/#the-log-store).

`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](#on-postgresql-or-sql-server) — 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`](https://github.com/pal-tamas/rask/blob/main/README.md) — nothing to install. It is **on**; an app that does without it says so:
>
> ```csharp
> 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

```csharp
// Program.cs
builder.Services.AddRaskLogging();
```

```jsonc
// 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

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

// AppDbContext.OnModelCreating
modelBuilder.AddRaskLogging();
```

```bash
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](#its-own-file-on-purpose)) 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](#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](https://rask.sh/docs/guides/data/#choosing-the-database).

### Options

Every option is `Rask:Logs` in `appsettings.json`:

```jsonc
{
  "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:

```csharp
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:

```jsonc
// appsettings.json — filters before the store ever sees the entry
"Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }
```

```jsonc
// …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*:

```jsonc
"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

```csharp
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:

```csharp
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](#on-postgresql-or-sql-server)):

- **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. |

```bash
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](https://rask.sh/docs/guides/dashboard/) — the Logs page that reads this store.
- [Observability](https://rask.sh/docs/guides/observability/) — logging categories, meters, tracing, health checks.
- [SQLite](https://rask.sh/docs/guides/sqlite/) — the production pragmas this store applies to its own file.
