Logging¶
Code:
src/Arius.Cli/CliBuilder.cs(CLI audit setup),src/Arius.Api/Composition/RepositoryProviderRegistry.cs(web per-repo rolling log),src/Arius.Core/Features/*/...Handler.cs(pipeline logs) · Decisions: ADR-0007 · Terms: content hash, chunk index, snapshot
Purpose¶
Every archive, restore, ls, and repair-index invocation writes a complete, self-contained audit trail to disk: the full pipeline log plus a plain-text capture of everything the user saw on screen. The on-screen output stays terse; the file is the forensic record for debugging and benchmark timing.
How it works¶
Two output channels, set up per invocation by each verb (ArchiveVerb.Build, RestoreVerb.Build, LsVerb.Build, RepairVerb.Build):
flowchart LR
H["ILogger<T> call sites<br/>(handlers + shared services)"] -->|Information+| F["Serilog file sink<br/>logs/{ts}_{cmd}.txt"]
S["AnsiConsole.MarkupLine /<br/>Live progress / tables"] --> R["Recorder<br/>(wraps AnsiConsole.Console)"]
R -->|live| T["terminal (what the user sees)"]
R -->|ExportText on flush| F
- File sink (the audit log).
CliBuilder.ConfigureAuditLogging(account, container, command)setsLog.Loggerto a single Serilog file sink atInformationminimum level, writing to~/.arius/{account}-{container}/logs/{yyyy-MM-dd_HH-mm-ss}_{command}.txt(path fromRepositoryLocalStatePaths.GetLogsDirectory, created if missing). Theupdateverb skips this — it has no repository context. EveryILogger<T>call site across Core and the CLI feeds this one sink. - Console (what the user sees). The CLI does not use a Serilog console sink. User-facing output is Spectre.Console (
AnsiConsole.MarkupLine, theLiveprogress display, summary/cost tables). Each verb swapsAnsiConsole.ConsoleforAnsiConsole.Console.CreateRecorder()for the duration of the run. The "console shows warnings and errors only" property is structural: only deliberate Spectre writes reach the screen, while informational pipeline detail goes solely to the file viaILogger. - Capture-on-flush. In the verb's
finally,CliBuilder.FlushAuditLog(recorder)callsrecorder.ExportText()and appends it to the log under a--- Console Output ---header, thenLog.CloseAndFlush(). So the file ends with an ASCII rendering of every table, progress summary, and error the user saw.
Line format. An ExpressionTemplate renders each file line as
[{HH:mm:ss.fff}] [{u3}] [T:{ThreadId}] [{ShortSourceContext}] {Message} (thread id from .Enrich.WithThreadId(), so concurrent hash/upload workers are distinguishable). {ShortSourceContext} is the class name peeled off the namespace-qualified Serilog SourceContext (Coalesce(Substring(..., LastIndexOf '.'), 'Arius')) — e.g. ArchiveCommandHandler, FileTreeBuilder, or Arius for context-less top-level crash logs.
Two-level taxonomy in {Message} (ADR-0007). Pipeline handlers prefix messages with category tags; shared services log plain messages (identified by {ShortSourceContext} instead). The three levels, all visible in ArchiveCommandHandler:
| Level | Tag | Example |
|---|---|---|
| Lifecycle | [archive] / [restore] / [repair] |
[archive] Done: scanned={..} uploaded={..} size={..} |
| Phase entry | [phase] <name> |
[phase] hash, [phase] tar-upload, [phase] snapshot |
| Detail | [hash] [dedup] [tar] [tree] [snapshot] [upload] [chunk-index] |
[hash] {Path} -> a1b2c3d4 (4.2 MB) |
[phase] markers are entry-only (no synthetic "complete"), because pipeline stages overlap — see ADR-0007. Detail logs exist only where they add payload beyond the phase marker.
Formatting conventions in messages. Hashes are truncated to 8 hex chars via ContentHash.Short8 / ChunkHash.Short8 / FileTreeHash.Short8 (Value[..8]) — full hashes stay in the data structures and storage. Sizes are humanized with Humanizer's bytes.Bytes().Humanize() (4.2 MB), never raw byte counts.
Key invariants¶
- One log file per invocation; one global
Log.Logger. Each verb callsConfigureAuditLoggingbefore doing work andFlushAuditLoginfinally, so the file is closed/flushed even on failure or crash (the top-levelcatchinProgram.csalso callsLog.Fatal+Log.CloseAndFlush). - The file sink is the only Serilog sink in the CLI — informational pipeline detail must never reach the terminal. New user-facing messages go through Spectre
AnsiConsole, notLogInformation. - Hashes are truncated in logs, never elsewhere. Truncation is a formatting concern (
.Short8); persisted/in-memory hashes remain full-length. - Phase markers are entry points, not spans. Don't add
[phase] X completelogs or a detail log that merely restates a phase (ADR-0007). Durations are read by diffing the millisecond timestamps of successive markers. - Category tags belong to handlers, plain messages to shared services. A service log line is attributed by
{ShortSourceContext}, so don't push handler-style[tag]prefixes into shared services likeChunkIndexServiceorFileTreeBuilder.
Why this shape¶
- The two-level phase/detail taxonomy and the no-end-marker rule are the subject of ADR-0007 — readable benchmark timing without pretending concurrent stages have sequential boundaries.
- Per-invocation file + Spectre capture means a single artifact reproduces both the trace and the operator's view, which is what you want when diagnosing a one-off archive/restore after the fact.
Per-host setup¶
All three hosts emit ILogger<T> to a Serilog file, but the unit of a file and the surrounding mechanics differ. The shape above (line format, phase/detail taxonomy) is the CLI's; the web host (RepositoryProviderRegistry.GetOrCreateRepoLoggerFactory) reuses the same directory and ExpressionTemplate but logs per repository (a long-running server can't open a fresh file per call), and the Explorer keeps its own scheme.
| Aspect | CLI — Arius.Cli |
Web — Arius.Api |
Explorer — Arius.Explorer |
|---|---|---|---|
| Setup site | CliBuilder.ConfigureAuditLogging (per verb) |
RepositoryProviderRegistry.GetOrCreateRepoLoggerFactory |
Program.Main |
| Logging unit | per invocation (one verb run) | per repository (shared across all its operations) | per app launch (one file per process) |
| Sinks | file only | rolling file + console | file only |
| File directory | ~/.arius/{account}-{container}/logs/ |
same ~/.arius/{account}-{container}/logs/ |
%LocalAppData%/Arius/logs/ |
| File name | {yyyy-MM-dd_HH-mm-ss}_{command}.txt |
arius-{yyyyMMdd}.txt (+_NNN on overflow) |
arius-explorer-{yyyyMMdd_HHmmss}.log |
| Rolling | none (new file per run) | daily + 100 MB cap (rollOnFileSizeLimit), keep 366 |
none (new file per launch) |
| Min level | Information |
Information |
Debug |
| Line format | shared ExpressionTemplate ([ts] [u3] [T:id] [ShortSourceContext] {msg}) |
identical ExpressionTemplate (copied from CLI) |
own outputTemplate (full SourceContext, date + zone) |
| Lifetime / flush | FlushAuditLog + Log.CloseAndFlush in verb finally |
factory disposed in registry.DisposeAsync at app shutdown |
Log.CloseAndFlush at app exit |
| Spectre console capture | yes (--- Console Output --- footer) |
no — progress via SignalR (see hosts/web.md) | no |
In the web host the per-repo logger is shared across both provider lifetimes — cached read providers (browse/stats/search) and per-job providers (archive/restore) all resolve the one factory in BuildAsync — so every Web-launched operation for a repo lands in the same rolling file, and a single instance funnels concurrent writes through one sink. Its lifetime is decoupled from providers: registered as an externally-owned singleton (AddSingleton(instance)), so neither Evict nor a job disposing its provider closes the log — only DisposeAsync does (flushing via AddSerilog(serilog, dispose: true), after the providers, so provider-disposal logging still lands).
Key invariants (web host)¶
- One rolling logger per repository, shared across providers. Don't build a file sink per provider — a job provider and a read provider for the same repo must write the same file through the same instance, or concurrent writes race and the file is split arbitrarily.
- Logger lifetime ≠ provider lifetime.
Evict(after archive / on a properties change) and job-provider disposal must never dispose the per-repo logger — the repo lives on and its log keeps writing. Only registry shutdown (DisposeAsync) or a repository delete (Remove, which evicts the provider and disposes the logger) closes it.
Open seams / future¶
- Hosts still diverge in setup (see the per-host table). The
ExpressionTemplateline format is duplicated CLI ↔ Api rather than shared — Core stays Serilog-free, so a shared package would have to live outside Core. Unifying setup would remove the per-host duplication. - Phase durations are inferred, not recorded. No machine-readable span data; tooling that wants exact phase timings must parse timestamps between
[phase]lines. RestoreCommandHandler/ListQueryHandleradoption. ADR-0007 expects these to reuse the same taxonomy; code review is the enforcement mechanism (plus tests asserting the agreed coarse phase names) rather than a type-level contract.