Logging & Verbosity
Starting in v9, Albatross.CommandLine keeps standard output and standard error clean by default. The core library does not add a logging provider, does not emit log lines, and no longer creates a global --verbosity option. A command owns its output; logging is an explicit, opt-in concern.
Important
Changed in v9. v8 added a recursive --verbosity/-v option to every command and logged to the console by default. That behavior has been removed. If you relied on it, see Migrating from v8 below.
The v9 Model
- Clean by default — with no logging configured, nothing is written to
stdout/stderrexcept what your command writes. This makes output safe to pipe, parse, and feed to tooling. - Opt in with Defaults — the
Albatross.CommandLine.Defaultspackage adds Serilog logging viaWithDefaults()/WithSerilog(). - Logging goes to a file, not the console — Serilog writes to a daily-rolling file under
IApplicationPath.LogRoot. No console sink is attached, so log lines never mix with command output. - Verbosity is configuration, not a CLI flag — the log level is controlled by a
Serilogsection inappsettings.json, editable at deploy time without recompiling.
Enabling Logging
Add the Albatross.CommandLine.Defaults package and call WithDefaults(). Logs are written to IApplicationPath.LogRoot; registering an IApplicationPath and passing its ConfigRoot lets configuration be read from the same stable location. Registering an IApplicationPath is optional — if none is registered, WithSerilog() falls back to a DefaultApplicationPath (see Where Logs Go):
using Albatross.CommandLine;
using Albatross.CommandLine.Defaults;
using Albatross.Config;
using Microsoft.Extensions.DependencyInjection;
static async Task<int> Main(string[] args) {
var appPath = new ApplicationPath(false, ["myapp"], "myapp", null, args);
appPath.Init();
await using var host = new CommandHost("My CLI App")
.RegisterServices((result, services) => {
services.AddSingleton<IApplicationPath>(appPath);
services.RegisterCommands();
})
.AddCommands()
.Parse(args)
.WithDefaults(appPath.ConfigRoot) // config + file logging
.Build();
return await host.InvokeAsync();
}
Using Logging in Command Handlers
Inject ILogger<T> into your handler to write log messages:
public class MyCommandHandler : BaseHandler<MyCommandParams> {
private readonly ILogger<MyCommandHandler> logger;
public MyCommandHandler(
ILogger<MyCommandHandler> logger,
ParseResult result,
MyCommandParams parameters) : base(result, parameters) {
this.logger = logger;
}
public override Task<int> InvokeAsync(CancellationToken cancellationToken) {
logger.LogTrace("This is a Trace log");
logger.LogDebug("This is a Debug log");
logger.LogInformation("This is an Information log");
logger.LogWarning("This is a Warning log");
logger.LogError("This is an Error log");
logger.LogCritical("This is a Critical log");
return Task.FromResult(0);
}
}
With the default configuration, messages at Information and above are written to the log file; Trace and Debug are filtered out until you raise the level (see below).
Controlling Verbosity
The log level is the Serilog:MinimumLevel setting. Because WithConfig() loads appsettings.json from the directory you pass to WithDefaults() (typically ConfigRoot), you can change verbosity by editing that file — no rebuild required:
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
}
}
Defaultsets the global minimum level (Verbose,Debug,Information,Warning,Error,Fatal).Overridesets per-namespace floors so framework noise can be suppressed independently of your own code.
Serilog levels map to Microsoft.Extensions.Logging.LogLevel as follows:
| Serilog Level | LogLevel |
|---|---|
| Verbose | Trace |
| Debug | Debug |
| Information | Information |
| Warning | Warning |
| Error | Error |
| Fatal | Critical |
Per-Environment Verbosity
WithConfig() also loads appsettings.{DOTNET_ENVIRONMENT}.json. Put a lower level in appsettings.Development.json and a higher one in appsettings.Production.json, and the level follows the environment automatically.
Where Logs Go
WithSerilog() writes to:
{IApplicationPath.LogRoot}/{entryAssemblyName}-.log
with a daily rolling interval, so the actual file is e.g. myapp-20260708.log. LogRoot is resolved from the IApplicationPath you register; its location depends on the ApplicationPath configuration (user vs. system paths). If no IApplicationPath is registered, WithSerilog() falls back to a DefaultApplicationPath, whose LogRoot is a log folder under the application base directory (AppContext.BaseDirectory). This keeps logging working with zero setup; register your own IApplicationPath when you want the log file somewhere more durable than the bin folder.
Zero-Config Baseline
Even with no Serilog section present, logging works. WithSerilog() sets these defaults in code, then layers any configuration on top:
- Minimum level
Information Enrich.FromLogContext()andEnrich.WithThreadId()- The file sink, using the default output template:
{Timestamp:yyyy-MM-dd HH:mm:sszzz} {SourceContext} {ThreadId} [{Level:w3}] {Message:lj}{NewLine}{Exception}
The file sink and its LogRoot-derived path are always added in code, so configuration never needs to know the log path.
Adding Sinks or Enrichers
Because ReadFrom.Configuration is layered on top of the code baseline, the Serilog section can add anything Serilog's configuration supports — additional sinks, enrichers, or filters:
{
"Serilog": {
"MinimumLevel": { "Default": "Information" },
"Enrich": [ "WithMachineName" ]
}
}
Reference the corresponding Serilog package (for example Serilog.Enrichers.Environment for WithMachineName) so the setting can be resolved.
Note
A sink whose secret is supplied through an environment variable (e.g. Serilog__WriteTo__Slack__Args__…) still needs its Name element defined in a JSON file. ReadFrom.Configuration fails fast at startup if a WriteTo entry has no Name.
Disabling Logging Entirely
Logging is off unless you opt in. Simply omit WithDefaults() / WithSerilog():
await using var host = new CommandHost("My CLI Application")
.RegisterServices(RegisterServices)
.AddCommands()
.Parse(args)
// No WithDefaults()/WithSerilog() — no logging provider is configured
.Build();
return await host.InvokeAsync();
ILogger<T> injections still resolve, but messages are not written anywhere.
Migrating from v8 Verbosity
| v8 | v9 |
|---|---|
Recursive --verbosity/-v option added automatically |
Removed — no global logging flag |
Default console logging at Error |
No logging by default; opt in with WithDefaults() |
| Logs written to the console | Logs written to a file under IApplicationPath.LogRoot |
Change level with -v Debug at the command line |
Set Serilog:MinimumLevel in appsettings.json |
CommandBuilder.VerbosityOption.DefaultValueFactory |
Set Serilog:MinimumLevel:Default in configuration |
Adding Your Own Recursive Option
The library no longer imposes a --verbosity flag, but nothing stops you from adding one — or any other cross-cutting option. CommandBuilder.RootCommand is public, so add a recursive option to it before Parse() and read the value afterward:
var host = new CommandHost("My CLI App");
// A recursive option is inherited by every command in the hierarchy.
var verbosity = new Option<LogLevel>("--verbosity", "-v") {
Recursive = true,
DefaultValueFactory = _ => LogLevel.Information,
};
host.CommandBuilder.RootCommand.Options.Add(verbosity);
host.RegisterServices(RegisterServices)
.AddCommands()
.Parse(args) // must come after the option is added
.WithConfig(appPath.ConfigRoot)
.Build();
You can then read the parsed value and wire it into your own logging setup. This is the same mechanism the library itself uses; there is no dedicated API to learn.