12 KiB
Logging
Custom logging framework with multi-writer fan-out and Microsoft.Extensions.Logging integration. Source: Loggers/ in this project.
For server-side GlobalLogger see
AyCode.Core.Server/docs/LOGGING/README.md. For remote writers (HTTP, browser console, SignalR) seeAyCode.Services/docs/LOGGING/README.md.
Design Overview
Logger holds a list of writers. Every log call fans out, filtered by two independent level gates:
logger.Info("msg")
│
├─ Gate 1: Logger.LogLevel <= Info? ← global minimum
│ NO → discard
│ YES ↓
├─ Writer[0].Write(...)
│ └─ Gate 2: Writer.LogLevel <= Info? ← per-writer minimum
│ NO → discard
│ YES → write to output
├─ Writer[1].Write(...)
│ └─ Gate 2: ...
└─ Writer[N].Write(...)
└─ Gate 2: ...
Class Hierarchy
IAcLogWriterBase (writer contract)
└─ AcLogWriterBase (abstract, config from appsettings)
├─ AcTextLogWriterBase (abstract, text formatting)
│ ├─ AcConsoleLogWriter (colored console output)
│ └─ AcBrowserConsoleLogWriter (AyCode.Services — Blazor JSInterop console)
└─ AcLogItemWriterBase<TLogItem> (AyCode.Entities — abstract, structured → ThreadPool + Mutex)
├─ AcDbLogItemWriter<TCtx,TItem> (AyCode.Database — EF Core database writer)
├─ AcHttpClientLogItemWriter<T> (AyCode.Services — HTTP POST as JSON)
└─ AcSignaRClientLogItemWriter (AyCode.Services — SignalR hub transport)
IAcLoggerBase (: IAcLogWriterBase, ILogger)
└─ AcLoggerBase (abstract, multi-writer fan-out)
└─ AcGlobalLoggerBase (sealed, used by GlobalLogger singleton)
└─ [concrete loggers in consuming projects]
IAcLogWriterClientBase (: IAcLogWriterBase) (marker for client-side writers)
Two writer branches:
| Branch | Base class | Output | Concurrency |
|---|---|---|---|
| Text | AcTextLogWriterBase |
Formatted string → WriteText(string, LogLevel) |
Depends on subclass (Console uses lock) |
| Structured | AcLogItemWriterBase<TLogItem> |
TLogItem object → WriteLogItemCallback(TLogItem) |
TaskHelper.RunOnThreadPool + Mutex |
LogLevel
public enum LogLevel : byte
{
Detail = 0,
Trace = 5,
Debug = 10,
Info = 15,
Suggest = 17,
Warning = 20,
Error = 25,
Disabled = 255,
}
⚠️ Values DB-synchronized with LogLevel table. Do NOT renumber.
Comparison <=: a logger/writer with LogLevel = Info processes Info, Suggest, Warning, Error (≥ 15).
Configuration
Config under AyCode:Logger in appsettings.json:
{
"AyCode": {
"Logger": {
"AppType": "Server",
"LogLevel": "Debug",
"LogWriters": [
{
"LogWriterType": "MyApp.Loggers.MyConsoleLogWriter, MyApp",
"LogLevel": "Info"
},
{
"LogWriterType": "MyApp.Loggers.MyDbLogWriter, MyApp",
"LogLevel": "Warning"
}
]
}
}
}
| Key | Type | Purpose |
|---|---|---|
AppType |
AppType enum |
Identifies the application (Server, Web, etc.). Stamped on every log entry. |
LogLevel |
LogLevel enum |
Global minimum — the logger's own gate. |
LogWriters[] |
array | One entry per writer. Each has LogWriterType (AssemblyQualifiedName) and LogLevel (per-writer gate). |
Writer Instantiation
AcLoggerBase constructor iterates LogWriters[] and calls Activator.CreateInstance(logWriterType, AppType, logWriterLogLevel, CategoryName) — each writer's ctor must accept (AppType, LogLevel, string?).
AcLogWriterBase also reads its own LogLevel from config by matching AssemblyQualifiedName against LogWriterType entries — a manually-instantiated writer (not via config) still picks up config values if a matching entry exists.
DI-Based Factory Pattern
Modern alternative to the config-reading AcLoggerBase(string) ctor. Concrete writer types resolved from DI; no runtime reflection over writer config. Recommended for MAUI, WASM, ASP.NET Core — the config-reading path is filesystem-bound and unsuitable for MAUI/WASM (see LOGGING_ISSUES.md#accore-log-i-r9p3).
Consumer setup in Program.cs
// 1. Bind options
services.Configure<AcLoggerOptions>(configuration.GetSection("AyCode:Logger"));
// 2. Register writer(s) as DI singletons
services.AddSingleton<IAcLogWriterBase, MyConsoleWriter>();
// Client-scoped marker variant:
// services.AddSingleton<IAcLogWriterClientBase, MyRemoteWriter>();
// 3. Register the logger factory (Func<string, TLogger> singleton in DI)
services.AddAcLoggerFactory<MyConcreteLogger>();
// Custom writer-marker overload — pulls only TWriterBase-registered writers:
// services.AddAcLoggerFactory<MyConcreteLogger, IAcLogWriterClientBase>();
Consumers inject Func<string, MyConcreteLogger> and invoke it with a category name.
Appsettings.json shape
{
"AyCode": {
"Logger": {
"AppType": "Web",
"LogLevel": "Debug"
}
}
}
Only AppType and LogLevel bind to AcLoggerOptions (defaults: Server / Info). Extend the POCO as needed; the binding picks up new properties automatically. The legacy LogWriters[] array (config-reading ctor) is unused in this pattern.
TLogger ctor requirement
AddAcLoggerFactory<TLogger> uses Activator.CreateInstance(typeof(TLogger), AppType, LogLevel, categoryName, writers) — TLogger must expose:
public TLogger(AppType appType, LogLevel logLevel, string? categoryName, params IAcLogWriterBase[] logWriters)
If the concrete logger doesn't match, register a manual factory instead:
services.AddSingleton<Func<string, MyConcreteLogger>>(sp => category => new MyConcreteLogger(...));
Companion extension: AddAcDefaults (SignalR)
For SignalR client setup, AcSignalRConnectionExtensions.AddAcDefaults(builder, logger, connectionOptions) bundles AddAcConnection + ConfigureLogging(AddAcLogger) in one call. See AyCode.Services/docs/SIGNALR/README.md.
Comparison: config-reading ctor vs DI-based factory
| Aspect | Config-reading ctor | DI-based factory |
|---|---|---|
| Writer instantiation | Activator.CreateInstance per writer entry |
Resolved from DI singletons |
| Writer config location | AyCode:Logger:LogWriters[] |
services.AddSingleton<IAcLogWriterBase, ...>() |
| Writer ctor requirement | (AppType, LogLevel, string?) |
Consumer-defined (via DI) |
| Writer lifecycle | Per-logger instance | Singleton, shared across loggers |
| Typical use | Legacy / server-side config-driven | Modern DI: MAUI, WASM, ASP.NET Core |
| MAUI/WASM-safe | ❌ (filesystem-bound AcEnv.AppConfiguration) |
✅ |
Both patterns can coexist — consumer picks per scenario. Related issues: LOGGING_ISSUES.md. Planned unification: LOGGING_TODO.md#accore-log-t-r7l3.
Core Components
AcLoggerBase
Abstract logger implementing both IAcLogWriterBase and ILogger. Central responsibilities:
- Writer management —
List<IAcLogWriterBase> LogWriters,GetWriters,Writer<T>() - Fan-out dispatch — Named methods (
Detail,Debug,Info,Warning,Suggest,Error) checkLogLevelthenForEachwriters - Write overloads — Terminal:
Write(AppType, LogLevel, text, caller, category, errorType, exMessage) - Write(IAcLogItemClient) — Accepts structured log items (from remote clients)
- ILogger bridge —
Log<TState>,IsEnabled,BeginScope(no-opNullScope) - [Conditional("DEBUG")] — Every named method has a
*Conditionalvariant stripped from Release builds
Constructors:
| Signature | Behavior |
|---|---|
(string? categoryName) |
Reads config from appsettings.json, creates writers via Activator.CreateInstance |
(string? categoryName, params IAcLogWriterBase[]) |
Reads AppType + LogLevel from config, uses provided writers |
(AppType, LogLevel, string?, params IAcLogWriterBase[]) |
Fully manual, no config reading |
AcLogWriterBase
Abstract writer base. Each writer has own LogLevel and AppType. Named methods (Detail, Debug, etc.) delegate to terminal Write(AppType, LogLevel, text, caller, category, errorType, exMessage).
Base Write throws NotImplementedException — subclasses must override either 7-param Write (text writers) or Write(IAcLogItemClient) (structured), or both.
AcTextLogWriterBase
Text formatting base for human-readable output. Overrides Write to format via GetDiagnosticText then calls abstract WriteText(string, LogLevel).
Output format:
[HH:mm:ss.fff] [S] [Level] [Category->Method] [ThreadId] Text
[ERROR_TYPE]: exception details...
- Timestamp:
DateTime.UtcNow.ToLocalTime()(local display, UTC storage) [S]— First character ofAppType(e.g.,S=Server,W=Web)- Level: Left-padded to 9 chars
- Category→Method: Left-padded to 54 chars
- Error text on new line only if
errorTypeis non-empty
AcConsoleLogWriter
Colored console output. Thread-safe via static readonly object ForWriterLock.
| LogLevel | Color |
|---|---|
| ≤ Trace | Gray |
| Debug – Info | White (default) |
| Suggest | Cyan |
| Warning | Yellow |
| ≥ Error | Red (with extra newlines) |
Microsoft.Extensions.Logging Bridge
ILogger Bridge
Log<TState> maps MS log levels to AC methods:
| Microsoft.Extensions.Logging.LogLevel | AyCode LogLevel | Method called |
|---|---|---|
| Trace | Detail | Detail() |
| Debug | Debug | Debug() |
| Information | Info | Info() |
| Warning | Warning | Warning() |
| Error | Error | Error() |
| Critical | Error | Error("[CRITICAL] ...") |
| None | Disabled | — (ignored) |
BeginScope → no-op NullScope (scopes not supported). ShortenCategoryNames (default true): when MS logging passes fully-qualified type names (e.g. Microsoft.AspNetCore.SignalR.HubConnectionHandler), shortens to class name only (HubConnectionHandler).
ILoggerProvider
AcLoggerProvider<TLogger> implements ILoggerProvider with a per-category ConcurrentDictionary<string, TLogger> cache. Factory function provided at registration.
// Add alongside default providers
builder.Logging.AddAcLogger<MyLogger>(categoryName => new MyLogger(categoryName));
// Replace all providers with only AcLogger
builder.Logging.UseOnlyAcLogger<MyLogger>(categoryName => new MyLogger(categoryName));
Log Item Entity Hierarchy
Log items flow across projects as a chain of types:
IAcLogItemClient (AyCode.Core — DTO interface)
└─ AcLogItemClient (AyCode.Entities — [MessagePackObject])
└─ AcLogItem (AyCode.Entities.Server — [Table("LogItem")])
IAcLogItemClient Fields: TimeStampUtc, AppType, LogLevel, ThreadId, CategoryName, CallerName, Text, ErrorType, Exception.
Patterns
[Conditional("DEBUG")] Pattern
Every named log method has a *Conditional counterpart (InfoConditional, ErrorConditional, ...) decorated [Conditional("DEBUG")] — stripped from Release builds. Provided by both AcLoggerBase and AcLogWriterBase. Use for dev-only diagnostics with zero release cost.
CallerMemberName Auto-Capture
Named log methods use [CallerMemberName] string? memberName = null — compiler auto-fills caller name. ILogger.Log<TState> (MS logging) uses EventId.Name if available, else "Log".
Key Source Files
| Component | Path |
|---|---|
| Logger base | Loggers/AcLoggerBase.cs |
| Writer base | Loggers/AcLogWriterBase.cs |
| Text writer base | Loggers/AcTextLogWriterBase.cs |
| Console writer | Loggers/AcConsoleLogWriter.cs |
| ILoggerProvider | Loggers/AcLoggerAdapter.cs |
| LogLevel enum | Loggers/LogLevel.cs |
| Log item DTO interface | Loggers/IAcLogItemClient.cs |
| Client marker | Loggers/IAcLogWriterClientBase.cs |