Table of Contents

Bsft.Logging

A structured file logger with daily rotation, size-based rolling and retention cleanup. It is a plain ILoggerProvider, so application code keeps using ILogger<T> and never references this package.

BsftLoggingOptions is the one type that describes file logging. Bsft.Hosting does not reimplement any of this: it references this package, and its BsftHostLoggingOptions nests these options behind FileLogging(...) — see hosting.md.

Setup

Two entry points, depending on whether you want the whole logging stack or just the file provider.

// The stack: clears providers, applies levels and filters, adds console + file.
services.AddBsftLogging(options =>
{
    options.LogDirectory = "logs";
    options.RetainDays = 14;
});

// Just the file provider, alongside whatever else is configured.
services.AddLogging(builder => builder.AddBsftFileLogger());

AddBsftLogging is opinionated: it calls ClearProviders(), sets the minimum level from the options, filters the Microsoft and System categories to Warning, adds the console provider, and adds the file provider when EnableFileLogging is true. Reach for AddBsftFileLogger on an ILoggingBuilder when you want the file output without the rest of the opinion.

Both take an optional configure delegate.

Options

Bsft.Logging.BsftLoggingOptions:

Property Type Default Meaning
EnableFileLogging bool true Whether AddBsftLogging adds the file provider.
LogDirectory string "logs" Directory; a relative path is resolved from the process working directory.
MaxFileSizeMb int 10 Size at which the current file rolls. Between 1 and 1024.
RetainDays int 30 Files older than this are deleted. Between 1 and 3650.
EnableJsonFormat bool false One JSON object per line instead of text.
MinimumLevel LogLevel Information Minimum level for the file logger.
LogReaderPollInterval TimeSpan 250 ms Poll interval for the live file tail. Must be positive.
MaxEntryBytes int 1048576 (1 MiB) Maximum UTF-8 bytes retained for one physical line or multiline entry. Must be positive.

MinimumLevel is the file sink's level, not the console's. Raising it quietens the file and leaves the console where it was. Lowering it below Information does reach the console as well, because the logging factory drops a record before it is offered to any provider, so the pipeline has to carry what the file asks for.

Files and format

The current file is app-{yyyy-MM-dd}.log. When it exceeds MaxFileSizeMb it rolls to app-{yyyy-MM-dd}_001.log, _002 and so on, so a single noisy day never produces one unreadable multi-gigabyte file. Retention is applied at startup and again whenever the day turns over, which is the one moment a running process has to prune without polling for it.

A text line looks like this:

[2026-03-24 14:30:00.123 INF] [abc-123-def] OrdersController: Order ORD-42 created

The bracketed field after the level is the correlation id. The logger does not resolve it from a service — it picks up a log scope entry whose key is CorrelationId, and writes - when no such scope is active. Pushing that scope is what CorrelationIdMiddleware does, so a correlated log file only requires app.UseBsftCorrelation() in the pipeline; see correlation.md. Levels are abbreviated TRC, DBG, INF, WRN, ERR, CRT.

Line breaks are stripped from the message and the correlation id before they are written, so a CRLF in either cannot forge an extra log line.

The scope key is the public constant Bsft.Correlation.LogScopeKeys.CorrelationId. Bsft.Correlation (the producer), the file logger and reader in Bsft.Logging and the console formatter in Bsft.Hosting all use it, so the field name cannot drift. It lives in Bsft.Correlation.Abstractions because that package has no dependencies at all: the producer does not have to take a log sink as a package dependency, and Bsft.Logging takes in no ASP.NET Core. Both packages are Foundation, so the tier rule allows the edge; the abstractions are simply the lightest place.

The correlation slot of a text line is escaped on write (a backslash and ] get a leading backslash, line breaks become a space) and unescaped on read, so an id such as a] {RunId=x} cannot end the slot early and pass the rest off as a property block. Ordinary ids contain neither character and are written unchanged. Other sinks write the same line format through Bsft.Logging.Sinks.IScopeLineEncoder (ScopeLineEncoder): the console formatter in Bsft.Hosting uses it, and BsftConsoleFormatterOptions.PersistedScopeKeys is validated by the file logger's rules (null list, blank key and the reserved marker key are refused when the options are read).

Persisting more scope keys

BsftLoggingOptions.PersistedScopeKeys lists the scope keys written on every line. The default is CorrelationId alone, which keeps its dedicated slot and leaves the output byte-identical to earlier releases. Any further key ("RunId", "Tenant", ...) is written after the correlation id, in the configured order, with the innermost value of that key that was in scope when the line was logged. Keys opened by different scopes are all persisted: a request scope carrying CorrelationId and a nested run scope carrying RunId yield a line with both, and a nested scope that repeats a key shadows the outer value only for the lines logged inside it. A key with no value in scope is left out, and a line with no such key has neither block nor object. Configuration binding adds to the default list, so Bsft:Logging:PersistedScopeKeys:0 = RunId persists the correlation id and RunId; the correlation slot cannot be removed through configuration, only by assigning a new list in code. AddBsftLogging(configuration) binds the Bsft:Logging section. AddBsftHostLogging builds the file logger options only through FileLogging(options => ...), so there the keys are set in code, and the host copies the list to the console formatter. The scope state has to be a list or a sequence of KeyValuePair<string, object>, which is what BeginScope(Dictionary<string, object>) and BeginScope("Run {RunId}", id) pass; other state objects carry no persisted key. The scope chain is process-wide, so two file providers in one process share it.

Text lines carry a {Key=value;Key=value} block between the correlation id and the category:

[2026-03-24 14:30:00.123 INF] [abc-123] {RunId=run-7;Tenant=acme} OrdersController: Order ORD-42 created

Inside the block a backslash escapes the next character; the writer escapes the backslash, ;, = and } in keys and values, so a block ends at the first unescaped }, a pair at the first unescaped ; and a key at the first unescaped =. JSON lines carry the same properties as a scope object after correlationId ("scope":{"RunId":"run-7","Tenant":"acme"}). Line breaks in values are replaced by a space in both formats.

The properties of one line are bounded by MaxScopePropertyChars (default 1024), counted as key plus value characters over all extra properties; the correlation id is not counted. A property that does not fit whole is dropped, never written cut, so an exact filter can miss such a line but never match a shortened value. Later, smaller properties may still fit. A line that lost a property carries the reserved marker ~truncated=1 (only the value 1 counts; in the block, or in the JSON scope object), which the reader lifts into LogFileEntry.IsScopeTruncated and keeps out of ScopeProperties; that key cannot be configured. Lines written before this existed have no block or object and decode unchanged. An old line whose category itself looks like {K=v} followed by a space would read as a block.

With EnableJsonFormat = true each line is a JSON object instead, which is what you want when a collector ingests the files.

Provider

BsftFileLoggerProvider is public and can be constructed directly, which is occasionally useful in tests:

public BsftFileLoggerProvider(BsftLoggingOptions options, TimeProvider? timeProvider = null)

Passing a TimeProvider is what lets a test drive rotation without waiting for midnight. The logger itself (BsftFileLogger) is internal — it is reached only through ILogger<T>.

Writes are queued and drained by a background thread, so a log call never waits on the disk. The consequence is that entries are on disk within about half a second rather than immediately, and entries still queued when the process is killed are lost. Dispose gives the flush thread and final drain a shared five-second budget. It normally drains the queue, but returns rather than hanging shutdown if an in-flight file write holds the writer lock past that budget; such an abandoned drain is reported separately from entries confirmed lost.

The registered writer and reader receive the same internal file-naming singleton, so formatting and parsing app-{yyyy-MM-dd}[_NNN].log cannot drift. The public provider and reader constructors cannot expose an internal DI seam; direct construction therefore uses the package's default naming instance while the registration path uses constructor injection.

Reading and tailing files

Both AddBsftLogging and AddBsftFileLogger register ILogFileAccess and Bsft.Logging.LogFiles.ILogFileReader as singletons. Calling both entry points still contributes one reader and one file-access service. The contract is owned by the Bsft.Logging package and has no UI dependency, so a local host can adapt it to its own viewer:

var page = await reader.ReadAsync(new LogReadRequest(PageSize: 100));
var older = await reader.ReadAsync(new LogReadRequest(100, page.NextBefore));

await foreach (var entry in reader.TailAsync(new LogReadRequest(Before: page.Entries[0].Sequence), ct))
{
    // append entry to the viewer
}

Pages are newest first. A next cursor is exclusive and points to the next older page. The tail is the opposite, yielding new entries in chronological order. Passing no tail cursor starts after the entry that is newest when enumeration begins; it does not replay the existing file.

LogReadRequest.CorrelationId (an init property) narrows a read or tail to the entries of one request. It is an exact, case-sensitive match against LogFileEntry.CorrelationId, the id the logger wrote on the line: the innermost CorrelationId scope that was active when the entry was logged (a nested scope shadows its parent for the lines logged inside it). It combines with MinimumLevel, IncludedLevels, SearchText and the paging cursor. A null, empty or whitespace value applies no correlation filter. An entry logged outside any scope, which the file marks with -, and a malformed line have a null CorrelationId, so no filter value selects them.

var page = await reader.ReadAsync(new LogReadRequest(PageSize: 100) { CorrelationId = "0HN7..." });

LogFileEntry.ScopeProperties (IReadOnlyDictionary<string, string>, empty when there are none) exposes every scope value read from the line: the correlation id under CorrelationId plus the properties of the block or scope object. LogReadRequest.ScopeFilter (an init property, not a constructor parameter) is the generic form of the correlation filter: every pair must equal the entry's value for that key exactly (ordinal, AND semantics), and a null or empty filter applies none (a whitespace value in ScopeFilter is an exact filter, unlike a whitespace CorrelationId, which applies none). CorrelationId is matched against the same map, so both can be combined and a disagreeing pair selects nothing. A CorrelationId key inside the property block or scope object of a line is ignored on read, so only the line's own slot states the id. A line with a correlation id and no other property builds its ScopeProperties map when it is first read, so a scan that never reads it allocates none. LogFileEntry still compares by value, the scope properties by content. The filter runs while entries are collected, so paging and tailing keep the properties described below.

var page = await reader.ReadAsync(new LogReadRequest(PageSize: 100)
{
    CorrelationId = "0HN7...",
    ScopeFilter = new Dictionary<string, string> { ["RunId"] = "run-7" }
});

The files are not indexed. A read walks the files newest first until it has PageSize matches, so an id that is rare, or absent, makes it read every retained file; use the filter to open the log of one request, not as a per-request query engine over long retention. A tail with a filter scans only what was appended since its last poll, whatever the filter selects.

The reader understands the exact structured-text and JSON formats above. A multiline text exception is returned on the same LogFileEntry; an unparseable line is returned with IsMalformed = true instead of disappearing. The unterminated final fragment of an actively written file is deferred until its newline arrives, so the reader never publishes a half-written entry. A physical line or combined multiline entry larger than MaxEntryBytes is bounded in memory and returned with IsTruncated = true; truncation removes partial UTF-8 code points at either retained boundary rather than emitting replacement characters.

Sequence values use a positive 63-bit layout: 22 bits for the DateOnly day number, 11 bits for roll indexes 0..2047, and 30 bits for the UTF-8 byte offset. That keeps them monotonic across rolls and process restarts and is the reason MaxFileSizeMb is capped at 1024. A manually supplied file whose roll index or entry offset is outside the layout makes the read fail with InvalidDataException instead of returning a colliding cursor.

Files are traversed backwards in bounded 64 KiB chunks with cancellable asynchronous reads and opened with FileShare.ReadWrite | FileShare.Delete, so a large active log is not loaded into memory, no worker thread is occupied by disk I/O, and reading does not prevent appending, rolling or deleting. TailAsync polls with the configured LogReaderPollInterval through TimeProvider, making the wait cancellable and testable. Every burst is reordered before emission so its Sequence values are strictly ascending.

ILogFileAccess is the replaceable local-file seam for directory discovery and shared file opening. AddBsftLogging registers the literal file-system implementation with TryAddSingleton, so a host can register its own implementation first without being replaced.

Security boundary

This package is local logging infrastructure, not a disclosure policy. Messages, correlation ids and exception text are written as supplied; there is no secret or personal-data redaction. Callers must avoid logging sensitive values or redact them before they reach ILogger.

LogDirectory and any replacement ILogFileAccess are trusted host configuration, never request or tenant input. Files inherit the operating system permissions of the configured directory and are not encrypted by this package, so production deployments must choose a directory and process identity that exclude untrusted local users.

ILogFileReader returns every matching local entry and deliberately performs no authentication, authorization or tenant filtering. A viewer or endpoint adapting it must enforce those controls before returning entries; MinimumLevel, IncludedLevels, SearchText and CorrelationId are result filters, not access controls.

Example

public sealed class OrderService(ILogger<OrderService> logger)
{
    public void Confirm(string orderNumber)
    {
        logger.LogInformation("Order {OrderNumber} confirmed", orderNumber);
    }
}

Nothing in the call site names this package. Swapping the provider out later is a change in one registration.

See also

  • hosting.md — AddBsftDefaults() configures logging as part of the host defaults.
  • correlation.md — where the id in each line comes from.