ALog is a logging framework for .NET 10+ with a small façade (Log.Write / Log.WriteAsync), message templates, scopes, and enrichers.
It ships console, file (size rotation), HTTP, SQL, Azure Application Insights, and AWS CloudWatch writers, plus a Microsoft.Extensions.Logging provider (ALog.Extensions.Logging) that binds the ALog configuration section.
An optional background queue can drop and count events when full (QueueFullMode.Drop, default) or apply backpressure (QueueFullMode.Block). Internal failures go to SelfLog so logging does not throw into the application.
- Intuitive API:
Log.Write(...),Log.WriteAsync(...) - Message templates:
Log.Write(LogLevel.Info, "User {UserId} logged in", userId) - Enrichers (
MachineName,ThreadId,ProcessId,Activity, custom) - JSON/env options via
ALogOptions/LoggerConfigFactory Microsoft.Extensions.Loggingprovider (ALog.Extensions.Logging) + ASP.NET sample- Formatters, scopes, background queue (drops and counts events when full, or applies backpressure with
QueueFullMode = Block),FlushAsync/Shutdown - Writers: Console, File, HTTP, SQL, Azure App Insights, AWS CloudWatch
- Benchmarks:
ALog.Benchmarks
How ALog's main package compares with the three most used .NET logging libraries. Links go to each project on GitHub.
Checked on 2026-10-27 against Serilog 4.4.0, NLog 6.2.2, and log4net 3.5.0 (latest stable on NuGet). Community packages are marked in the footnotes.
Time and memory per event when writing to a file, with each library's own async or buffered file setup. Lower is better in every row.
| ALog | Serilog | NLog | log4net | |
|---|---|---|---|---|
| Plain message, 1M events (ns per event) | 600 | 1,450 | 1,020 | 1,400 |
| Message template, 1M events (ns per event) | 1,050 | 1,800 | 1,320 | n/a13 |
| JSON output, 1M events (ns per event) | 1,300 | 5,450 | 1,700 | n/a14 |
| Exception with 3-level chain, 100k events (ns per event) | 22,400 | 30,700 | 28,800 | 31,800 |
| 8 threads, 1M events (ns per event) | 720 | 2,540 | 1,360 | 1,690 |
| Cold start to first event written (ms) | 26.0 | 128 | 102 | 94.8 |
| Memory allocated per event, plain message (bytes) | 152 | 680 | 193 | 923 |
| Peak working set, plain message (MB) | 61.0 | 71.3 | 70.9 | 71.0 |
ALog is behind NLog in four measurements: memory per event with message templates (632 B vs. 457 B, and 639 B vs. 263 B in BenchmarkDotNet), memory per event with exceptions (16,400 B vs. 13,300 B), and the BenchmarkDotNet time for logging an exception that was already thrown (197 ns vs. 64.8 ns).
Measured on 2026-10-27 with ALog 0.2.1, Serilog 4.4.0 (Serilog.Sinks.File 7.0.0), NLog 6.2.2, and log4net 3.5.0 on .NET 10.0.14, GitHub Actions ubuntu-24.04 (AMD EPYC 7763, 4 cores), mean of 5 runs. Every row shown here also held in 9 further runs on a separate Linux machine, with ALog at least 5% ahead of each library in every environment, all on commit b3daa7c. Harness, method, and all results, including where ALog is behind: the harness at b3daa7c and RESULTS.md.
dotnet add package ALog
dotnet add package ALog.Extensions.LoggingRelease zips are on the Releases page: Lite (ALog-v0.2.1-Lite.zip) covers Console, File, HTTP and Azure Application Insights on any platform (HTTP and Azure Application Insights only when configured in code; JSON config still supports only Console and File); Full is per RID (ALog-v0.2.1-Full-win-x64.zip, ALog-v0.2.1-Full-linux-x64.zip). For other platforms, or for SQL/AWS outside those RIDs, use NuGet.
Source: https://github.com/Chookees/ALog · Optional future sink split: docs/PACKAGE_SPLIT.md
using ALog;
using ALog.Config;
using ALog.Writers.Console;
Log.Init(new LoggerConfig().AddWriter(new ConsoleLogWriter()));
Log.Write("Hello");using ALog.Extensions.Logging;
builder.Logging.ClearProviders();
builder.Logging.AddALog(builder.Configuration); // binds "ALog" sectionTurn on ALog's console in appsettings.json:
{ "ALog": { "Console": { "Enabled": true } } }With AddALog, choose one console: keep the Microsoft console provider and leave ALog's console off, or call builder.Logging.ClearProviders() and set ALog:Console:Enabled to true (as in the ALog.AspNetSample project in DemoApp/AspNet). With both, every line appears twice.
ALog reads its settings, including MinimumLevel, once at startup. Changes while the app runs have no effect; restart the app.
Inject ILogger<T> as usual:
using Microsoft.Extensions.Logging;
public sealed class OrdersController(ILogger<OrdersController> logger)
{
public void Place(int id) =>
logger.LogInformation("Order {OrderId}", id);
}With "Formatter": "json" (and JsonPretty false), that event is one NDJSON line shaped like JsonFormatter output (null fields omitted):
{"timestamp":"2026-10-26T21:15:55.0000000Z","level":"Info","message":"Order 17","messageTemplate":"Order {OrderId}","category":"OrdersController","properties":{"OrderId":17},"context":{}}See the ALog.AspNetSample project in DemoApp/AspNet.
Complete appsettings.json keys bound from ALogOptions (section name ALog):
{
"ALog": {
"MinimumLevel": "Info",
"AsyncEnabled": true,
"BackgroundQueueEnabled": false,
"BackgroundQueueCapacity": 1000,
"BackgroundQueueBatchSize": 10,
"BackgroundQueueFlushIntervalMs": 100,
"QueueFullMode": "Drop",
"FileBufferSize": 65536,
"FileFlushInterval": "00:00:01",
"FlushTimeout": "00:00:05",
"Enrichers": [ "MachineName", "ThreadId", "ProcessId", "Activity" ],
"Formatter": "json",
"JsonPretty": false,
"Console": {
"Enabled": true,
"UseColors": true
},
"File": {
"Enabled": true,
"Path": "logs/app.log",
"MaxFileSizeInBytes": 1048576
}
}
}Environment variables use the same section with __ nesting (for example ALog__MinimumLevel, ALog__Console__Enabled, ALog__File__Path).
using ALog;
using ALog.Config;
using ALog.Writers.Console;
using ALog.Writers.File;
using ALog.Formatters;
var config = new LoggerConfig()
.AddWriter(new ConsoleLogWriter(useColors: true, formatter: new PlainTextFormatter("HH:mm:ss")))
.AddWriter(new FileLogWriter("logs/app.log", new JsonFormatter(pretty: false), maxFileSizeInBytes: 1_048_576)) // 1 MB
.SetMinimumLevel(LogLevel.Debug);
Log.Init(config);Use JsonFormatter(pretty: false) for files (NDJSON: one JSON object per line). pretty: true is for display only; a file of pretty-printed objects is not valid JSON or JSON Lines.
The exception overload takes the level last (Log.Write(ex, "msg", LogLevel.Error)); the template overload takes the level first (Log.Write(LogLevel.Info, "User {UserId} logged in", 42)).
Log.Write("Application started");
using (Log.BeginScope("userId", 42))
{
using (Log.BeginScope("feature", "Login"))
{
Log.Write(LogLevel.Info, "User {UserId} logged in", 42);
}
}
using (Log.BeginScope("orderId", 17))
{
using (Log.BeginScope("feature", "Checkout"))
{
try
{
throw new InvalidOperationException("Card declined");
}
catch (InvalidOperationException ex)
{
Log.Write(ex, "Payment failed", LogLevel.Error);
}
}
}
await Log.WriteAsync("Async log message");
await Log.FlushAsync();
Log.Shutdown();Step 1 and Step 2 together are a complete program: the ALog.QuickstartSample project in DemoApp/Quickstart. Running it prints this to the console:
[22:48:09] [Info] Application started
[22:48:09] [Info] User 42 logged in | props: UserId=42 | ctx: feature=Login, userId=42
[22:48:09] [Error] Payment failed | InvalidOperationException: Card declined | at Program.Main in Program.cs:33 | ctx: feature=Checkout, orderId=17
[22:48:09] [Info] Async log message
logs/app.log of the same run (JsonFormatter(pretty: false), NDJSON):
{"timestamp":"2026-10-26T22:48:09.3570731Z","level":"Info","message":"Application started","properties":{},"context":{}}
{"timestamp":"2026-10-26T22:48:09.3928381Z","level":"Info","message":"User 42 logged in","messageTemplate":"User {UserId} logged in","properties":{"UserId":42},"context":{"feature":"Login","userId":42}}
{"timestamp":"2026-10-26T22:48:09.3940362Z","level":"Error","message":"Payment failed","exception":{"type":"System.InvalidOperationException","message":"Card declined","stackTrace":" at Program.\u003CMain\u003E$(String[] args) in /_/Program.cs:line 33","source":{"method":"Program.Main","file":"Program.cs","line":33}},"properties":{},"context":{"feature":"Checkout","orderId":17}}
{"timestamp":"2026-10-26T22:48:09.4468376Z","level":"Info","message":"Async log message","properties":{},"context":{}}
The console output and these log lines come from a real run of the sample, unedited.
Pretty form of the template line (display only — do not write a file this way):
{
"timestamp": "2026-10-26T21:38:51.9798504Z",
"level": "Info",
"message": "User 42 logged in",
"messageTemplate": "User {UserId} logged in",
"properties": {
"UserId": 42
},
"context": {
"feature": "Login",
"userId": 42
}
}For high-performance scenarios, enable the background queue:
var config = new LoggerConfig()
.AddWriter(new ConsoleLogWriter())
.AddWriter(new FileLogWriter("logs/app.log"))
.UseBackgroundQueue(enabled: true, capacity: 1000, batchSize: 10, flushInterval: TimeSpan.FromMilliseconds(100))
.SetMinimumLevel(LogLevel.Debug);
Log.Init(config);
// Logs are queued and processed in background (BackgroundQueueEnabled == true)
Log.Write("This will be processed asynchronously");
await Log.FlushAsync();
Log.Shutdown();When the queue is full, ALog drops the new event by default and counts it (SelfLog ALOG0070). To make the caller wait instead, set QueueFullMode = Block (in JSON: "ALog": { "QueueFullMode": "Block" }). Even with Block, a call from the queue's own writer thread or after shutdown has started is dropped and counted, so logging can never deadlock.
using ALog.Writers.Http;
using ALog.Writers.Sql;
using ALog.Writers.Cloud;
// HTTP Writer
var httpWriter = new HttpLogWriter(
endpoint: "https://api.example.com/logs",
method: HttpMethod.Post,
headers: new Dictionary<string, string> { ["Authorization"] = "Bearer token" }
);
// SQL Writer (tableName: ^[A-Za-z_][A-Za-z0-9_]*$, max 128 chars)
var sqlWriter = new SqlLogWriter(
connectionString: "Server=localhost;Database=Logs;Integrated Security=true;",
tableName: "ApplicationLogs"
);
// Azure Application Insights (correct /v2/track payload; iKey in body)
var azureWriter = new AzureLogWriter(instrumentationKey: "your-instrumentation-key");
// AWS CloudWatch Logs (default AWS credential chain, or pass accessKey/secretKey / custom transport)
var awsWriter = new AwsCloudWatchWriter(
logGroupName: "/aws/application/alog",
logStreamName: "app-stream",
region: "eu-central-1");
var config = new LoggerConfig()
.AddWriter(httpWriter)
.AddWriter(sqlWriter)
.AddWriter(azureWriter)
.AddWriter(awsWriter);
Log.Init(config);AWS SDK V4 notes: see Migrating from 0.2.0.
If the log file can't be reopened, ALog retries at most once per second and reports it once a minute (SelfLog ALOG0073). It keeps up to 4 MiB of lines for the file, with or without a background queue, and drops and counts the rest (SelfLog ALOG0070). Events still in the background queue are limited by the queue capacity, not by this limit.
| Option | Default | Range | Meaning |
|---|---|---|---|
QueueFullMode |
Drop |
Drop, Block |
Full background queue: drop and count, or make the caller wait |
FileBufferSize |
64 KiB (65536) |
4 KiB … 16 MiB (4096 … 16777216) |
Bytes FileLogWriter collects before writing |
FileFlushInterval |
1 s | 0 … 60 s | How often a partial buffer is written; 0 writes every event |
FlushTimeout |
5 s | 0 … 60 s | How long Flush, Dispose, and Shutdown wait for queued events; leftovers counted as ALOG0071 |
- On every OS,
Flushcloses the write handle and reopens the file for reading so readers can open it, which makes each call slower. That matters most on Windows, whereFile.ReadAllTextneeds that reopen. Don't callFlushafter every line. - Each log file has one writer process: give each process its own file. Other programs can read, tail, and rotate it. On Windows,
File.ReadAllTextasks forFileShare.Read, so it can open the file only afterFlush, when the flush interval passes with no new lines (not withFileFlushInterval0), or after dispose. To read while lines are being written, open the file withnew FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete). - If the process crashes, the file can miss up to one
FileFlushIntervalor oneFileBufferSizeof lines, whichever comes first. SetFileFlushIntervalto0if every line must reach the operating system immediately.
When maxFileSizeInBytes is set and the logical file length is already at least that value, ALog renames the open file to {name}_{yyyyMMdd_HHmmss}{ext} (UTC stamp) in the same directory and opens a new file at the original path. A same-second rename overwrites that stamped path; older stamped files are not deleted, so retention is unbounded unless you prune them externally.
SelfLog reports internal failures so logging can continue without throwing into the app. By default it writes one line to stderr. SelfLog.Enable replaces that target; SelfLog.Disable stops output.
SelfLog.Enable(line => Console.Error.WriteLine(line));
// ...
SelfLog.Disable();Catalog codes (some lines include the code text; others are identified only in source comments):
| Code | Meaning |
|---|---|
| ALOG0022 | A Microsoft.Extensions.Logging message could not be formatted; ALog still writes the event with the message template only (once per category and exception type, up to 256 pairs) |
| ALOG0070 | Events dropped: background queue full, or file reopen hold reached the 4 MiB / line limit (or a line could not be formatted while held) |
| ALOG0071 | Queued or held events were not written before dispose / shutdown deadline |
| ALOG0072 | First event after shutdown/dispose closed the queue or file; later closed drops are counted, not reported again |
| ALOG0073 | Log file could not be reopened; ALog retries at most once per second and reports at most once per minute |
| ALOG0105 | A queue/file option was changed after startup seal; the new value was ignored (restart to apply) |
- Symptom: Construction rejects the name.
- When it happens: Name is not
^[A-Za-z_][A-Za-z0-9_]*$or longer than 128 characters; schema-qualified or quoted names. - Workaround: Use a simple identifier that matches the pattern.
- Symptom: Writers stop or are disposed when you did not expect it.
- When it happens:
AddWritertransfers ownership to the engine. - Workaround: Call
Log.Shutdown()(orLog.Reset()) to dispose writers. PreferShutdownbefore a secondInit.
- Symptom: Your
HttpClientor AWS transport is still alive after ALog shuts down (or the reverse expectation). - When it happens: You injected
HttpClientintoHttpLogWriter/AzureLogWriter, orIAwsCloudWatchLogsTransportintoAwsCloudWatchWriter. - Workaround: Dispose those injections yourself; ALog does not dispose them.
- Symptom: Errors from the SQL, HTTP, and cloud writers appear on stderr and in SelfLog (
[ALog] {WriterName} failed: …); the app keeps running. - When it happens: Credentials or endpoints reject the request, or (Lite) Microsoft.Data.SqlClient is missing on the first
SqlLogWriterwrite / table create. - Workaround: Fix credentials/endpoints or deploy the optional dependency; watch stderr / SelfLog.
- Symptom: Buffered lines of a failed flush are gone; stderr gets one message per failed flush; those events still count as written.
- When it happens: After the log file opened successfully, a later flush fails (for example disk full or I/O error).
- Workaround: Watch free disk space and stderr; keep logs on a volume with headroom.
- Symptom: Further lines are lost with only stderr output and no SelfLog event.
- When it happens: The log file is renamed or moved while the writer still holds its write handle, and every later reopen fails (for example something else now occupies the path).
- Workaround — before external rotation:
await Log.FlushAsync()(it can return early; see FlushAsync early return below).- Wait one more
FileFlushInterval(skip this wait whenFileFlushIntervalis0). - With
FileFlushInterval0 the write handle stays open, so waiting does not help: keep theFileLogWriterinstance you pass toAddWriterand call itsFlush()afterawait Log.FlushAsync(), before rotating.FileLogWriter.Flush()alone does not drain the background queue. Or rotate with tools that leave the path writable. Watch stderr.
- Symptom: Each event tries one open and writes
Write failedto stderr, with no SelfLog event; the queue counts the event as written. - When it happens: The very first open fails (for example a read-only directory).
- Workaround: Make sure the directory is writable at startup (see Platform-Specific Paths) and watch stderr.
- Symptom: The file is missing the last event(s) even though
await Log.FlushAsync()completed. - When it happens: About 1 in 2000 calls, and up to about 1 in 3 on the first call in a fresh process.
- Workaround: Use
Log.Shutdown()when you must read the file right afterwards.
Built-in IPlatformHelper implementations resolve safe, writable log paths on Windows, Linux, and iOS only:
| Platform | Type | Namespace |
|---|---|---|
| Windows | WindowsPlatformHelper |
ALog.Platform.Windows |
| Linux | LinuxPlatformHelper |
ALog.Platform.Linux |
| iOS (MAUI / Xamarin) | IOSPlatformHelper |
ALog.Platform.iOS |
using ALog.Platform.Windows; // or .Linux / .iOS
var logPath = new WindowsPlatformHelper().ResolveLogFilePath("logs/app.log");You control the log location – ALog does not enforce platform helpers. They are optional and recommended for mobile or portable environments.
| Writer | Description |
|---|---|
ConsoleLogWriter |
Outputs to console with optional color and formatting |
FileLogWriter |
Outputs to file with optional rolling and formatter support |
HttpLogWriter |
Sends logs to HTTP endpoints (REST APIs, webhooks) |
SqlLogWriter |
Stores logs in SQL Server database |
AzureLogWriter |
Application Insights /v2/track (iKey in body) |
AwsCloudWatchWriter |
CloudWatch Logs via AWSSDK (or injected transport) |
Namespaces: ALog.Writers.Console, ALog.Writers.File, ALog.Writers.Http, ALog.Writers.Sql, ALog.Writers.Cloud.
Without the background queue, the file writer writes on the calling thread, so a slow disk or network share slows down the caller. Enable the background queue to decouple callers from file I/O.
If you pass your own HttpClient to HttpLogWriter or AzureLogWriter, its handler must support synchronous Send, or enable the background queue. Use AwsCloudWatchWriter with the background queue: the AWS SDK has no synchronous API.
| Formatter | Description |
|---|---|
PlainTextFormatter |
One line (customizable time). Exceptions read Type: Message, with inner exceptions and at Class.Method in File.cs:42. includeStackTrace: true appends the full stack. |
JsonFormatter |
Structured JSON. exception keeps type, message, and stackTrace, and adds source (method, file, line) and inner (up to 16). Compact (pretty: false) is one line per event (NDJSON-friendly); pretty: true indents for display. |
Namespace: ALog.Formatters. Config-level SetFormatter applies only to writers that do not already have a formatter (single format pass).
ALog writes to the console only when ALog:Console:Enabled is true (or you add ConsoleLogWriter in code). An exception shares that line. The form is Type: Message, then inner exceptions, then the source frame. Types in System.* use the short name; every other type keeps its namespace. A thrown exception names the application frame:
[20:55:19] [Error] Payment failed | InvalidOperationException: Card declined | at CheckoutService.Pay in CheckoutService.cs:42 | ctx: orderId=17
| and --> in the message, the category, and context or property names and values are escaped as \| and --\>, line breaks as \r and \n, and a backslash is doubled only where it would otherwise read as one of these escapes (C:\temp stays as is), so each event stays one line. PlainTextFormatter(includeStackTrace: true) appends the full stack trace indented by four spaces. The default is off.
PlainTextFormatter(includeStackTrace: true) calls .NET's Exception.ToString(), and a self-referencing exception built via reflection can overflow inside that call.
Log.BeginScope adds temporary key-value pairs removed when the scope ends (see Quick Start Step 2). Supported formatters include them in plain text and JSON.
- Modular sink packages (see
docs/PACKAGE_SPLIT.md) - NuGet logo / package icon
- Breaking (behavior): ALog writes to the console only when
ALog:Console:Enabledistrue. Before,AddALogadded its own console output next to the Microsoft console provider, so every line appeared twice.
- Breaking (dependency):
AWSSDK.CloudWatchLogsmoves from 3.7.509.3 (AWS SDK for .NET V3, end of support since 2026-06-01) to 4.0.106 (V4). If your app references otherAWSSDK.*packages, upgrade all of them to V4 together; otherwise restore fails with NU1107 (version conflict onAWSSDK.Core), or it succeeds with warning NU1608 and the app throwsMissingMethodExceptionat runtime.
Contributions welcome! Fork the repository and submit a PR.
dotnet build ALog.sln -c Release
dotnet test ALog.sln -c Release --no-build
dotnet format whitespace ALog.sln --verify-no-changesTo see why a test was skipped, run dotnet test --logger "console;verbosity=normal".
For ideas like new formatters or writers, feel free to open a discussion first.
Apache License 2.0 © Artur Bobb. See LICENSE and NOTICE. Versions up to and including 0.2.0 were released under the MIT License.
Built and maintained by Artur Bobb / Chookees
Footnotes
-
log4net has no message templates (structured events) in its main package or a maintained package, see ILog. ↩
-
Community package, not maintained by the Serilog project. ↩ ↩2 ↩3 ↩4 ↩5
-
log4net has no HTTP writer in its main package or a maintained package, see Appenders. ↩
-
log4net has no maintained Application Insights output; Microsoft.ApplicationInsights.Log4NetAppender is out of support, see Migrate Application Insights Classic API SDKs to OpenTelemetry. ↩
-
Community package, not maintained by the log4net project. ↩ ↩2
-
log4net has no background queue that drops and counts, or blocks, when full in its main package or a maintained package, see BufferingForwardingAppender. ↩
-
log4net has no JSON configuration in its main package or a maintained package, see Configuration. ↩
-
ALog rotates files by size only, see Size rotation. ↩
-
ALog keeps rotated files until you delete them, see Size rotation. ↩
-
ALog reads JSON configuration only, see Configuration. ↩
-
log4net has no message templates or named properties. ↩
-
log4net has no built-in JSON layout. ↩
