Logging
🌍 Language: English | Español
Structured logging infrastructure for Delphi inspired by .NET’s Microsoft.Extensions.Logging. Write log entries via a unified ILogger interface — swappable providers (NNLog, console, custom) registered at startup.
Why use this?
- 🎚️ Six log levels — Trace, Debug, Information, Warning, Error, Critical
- 📝 Structured logging — Named
{Key}placeholders in message templates - 🔌 Provider model — Register one or more
ILoggerProviderimplementations; all receive each entry - 🏷️ Categories — Loggers are tagged by class or string name for filtering
- 🔗 Scopes — Attach contextual data to a block of log entries with
BeginScope - 🛠️ DI-ready —
AddLogging(Services, ...)registersILoggerFactoryin the container
Quick Start
uses
Daf.Extensions.Logging,
Daf.Logging,
Daf.Logging.Builder,
Daf.Logging.Provider,
Daf.NNLog; // any provider
// Standalone (no DI)
var Factory := TMultiProviderLoggerFactory.Create;
Factory.AddProvider(TNNLogProvider.Create(Targets, Rules));
var Logger := Factory.CreateLogger('MyApp');
Logger.LogInformation('Server started on port {Port}', [8080]);
Logger.LogError(Ex, 'Unhandled exception in {Operation}', ['ProcessOrder']);Log Levels
| Level | Value | When to use |
|---|---|---|
Trace |
0 | Extremely detailed — disabled in production |
Debug |
1 | Development diagnostics |
Information |
2 | Normal app flow milestones |
Warning |
3 | Unexpected but recoverable situations |
Error |
4 | Failures in the current operation |
Critical |
5 | Requires immediate attention |
None |
6 | Disables logging |
Structured Logging
Use named placeholders in message templates:
Logger.LogInformation('User {UserId} logged in from {IP}', [UserId, RemoteIP]);
Logger.LogWarning('Retry {Attempt} of {Max} for {Operation}', [3, 5, 'SendEmail']);The template and arguments are kept together in TLogState, letting providers format or index them independently.
Integration with DI and Hosting
uses Daf.Extensions.Logging, Daf.Logging.Builder, Daf.NNLog;
// While composing startup, on the builder's Services:
AddLogging(Builder.Services, procedure(Log: ILoggingBuilder)
begin
Log.AddProvider(TNNLogProvider.Create(Targets, Rules));
end);
// Resolve in a service:
var Factory := Provider.GetRequiredService<ILoggerFactory>;
var Logger := Factory.CreateLogger<TMyService>;AddLogging with no provider raises ELoggingCompositionError: a pipeline with no destination accepts every entry and drops it, and that is indistinguishable from one that works. If you want the entries dropped, say so with Builder.AddNullProvider. See Composing with no destination is an error.
Documentation
- 📖 Usage Guide — scopes, structured logging, providers, DI, creating custom providers
Abstractions
Logging.Abstractions
🌍 Language: English | Español
Core contracts for the DAFce logging infrastructure. Reference these interfaces when building components that emit log entries or when implementing custom log providers — without taking a dependency on any concrete logging implementation.
All types live in Daf.Extensions.Logging.
Interfaces
ILogger
Per-category façade that emits log entries.
ILogger = interface(IInvokable)
function IsEnabled(const Level: TLogLevel): Boolean;
procedure Log(const Level: TLogLevel; const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure Log(const Level: TLogLevel; const Ex: Exception; const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogTrace (const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogDebug (const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogInformation(const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogWarning (const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogError (const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogError (const Ex: Exception; const Msg: string = ''; const Args: TArray<TValue> = nil); overload;
procedure LogCritical (const Msg: string; const Args: TArray<TValue> = nil); overload;
procedure LogCritical (const Ex: Exception; const Msg: string; const Args: TArray<TValue> = nil); overload;
function BeginScope(const Msg: string; const Args: TArray<TValue>;
const ScopedProc: TProc = nil): ILogScopeVoid; overload;
end;The arguments travel as TArray<TValue>, so ['a', 1, True] keeps each value typed instead of flattening into a TVarRec. Every one of these has a further overload taking a TEventId first.
IsEnabled(Level) answers whether an entry of that level would reach any destination. Ask it before building arguments that cost more than the line itself — a state dump, a query, a walk — because in Delphi the arguments are evaluated at the call, so LogDebug(Dump) pays for Dump even with Debug off:
if Log.IsEnabled(TLogLevel.Debug) then
Log.LogDebug('state = {State}', [ExpensiveDump]);A composite answers yes if any destination would write; a destination that cannot say answers yes, because dropping a line by not knowing is worse than composing one too many. This is Microsoft.Extensions.Logging’s ILogger.IsEnabled.
ILoggerFactory
Creates ILogger instances; the logger it hands back writes to every registered provider.
ILoggerFactory = interface(IInvokable)
function CreateLogger(const Category: string): ILogger; overload;
function CreateLogger(const AClass: TClass): ILogger; overload;
function CreateLogger(const AType: PTypeInfo): ILogger; overload;
end;CreateLogger(TMyClass) takes the category from the class — its unit and its name. Providers are added through ILoggingBuilder, not here.
ILogger<T>
The logger a service asks for by naming itself. It adds no methods to ILogger; what it adds is that its category is already the category of whoever asked for it:
type
TPayrollService = class(TInterfacedObject, IPayroll)
strict private
FLog: ILogger<TPayrollService>;
public
constructor Create(const Log: ILogger<TPayrollService>);
end;
procedure TPayrollService.Run;
begin
FLog.LogInformation('the payroll ran'); // comes out under Payroll.TPayrollService
end;Nothing is registered for that service, and nothing is registered for the next one either. AddLogging puts in one registration, with its type argument left open, that serves every closure — the same shape .NET writes as services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)). See registering a generic contract once for what that is and what it costs.
What it buys is that the category cannot drift. Written by hand it appears twice — the name of the class, and the string that names it — with nothing checking that the two agree; and when the class is renamed, the string stays behind. Here it is derived from the class, so renaming carries it along and the composition is not touched.
The lifetime is per closed type: ILogger<TPayroll> and ILogger<TInvoicing> are two loggers, and asking twice for either one gives the same one back.
ILoggerProvider
Produce ILogger instances for a specific output destination.
ILoggerProvider = interface
function CreateLogger(const Category: string): ILogger;
end;ILoggingBuilder
Fluent interface for configuring providers before the host starts (used inside AddLogging).
ILoggingBuilder = interface
procedure AddProvider(Provider: ILoggerProvider);
procedure AddNullProvider;
function Build: ILoggerFactory;
end;AddNullProvider is how an application says it wants its entries dropped. Composing with no provider at all is not the same thing — that raises ELoggingCompositionError, because a pipeline with no destination is indistinguishable from one that works. See Logging — Usage Guide.
ILogScope / ILogScopeVoid
Represent an active logging scope. ILogScopeVoid is the version returned by BeginScope when the scope proc does not return a value.
Value types
TLogLevel
{$SCOPEDENUMS ON}
TLogLevel = (Trace, Debug, Information, Warning, Error, Critical, None);Scoped, so it is written TLogLevel.Debug. Text goes in and comes out through a helper, which is what a level read from a configuration file needs:
S := Level.ToString;
Level := TLogLevel.Parse('Debug'); // raises: Invalid log level: <text>
Level := TLogLevel.Parse(Raw, TLogLevel.Information); // or a fallback, and no exception
Levels := TLogLevel.ParseList('Debug,Error'); // comma separated
if TLogLevel.TryParse(Raw, Level) then ...⚠️ ParseList drops silently what it cannot read: a list of three with one typo comes back with two levels and no error.
TLogState
The message an entry carries: the template as written, its arguments, and — when the template names them — the pairs it yields.
| Member | What it gives |
|---|---|
Template |
the template as written, placeholders included |
Arguments |
TArray<TValue>, in the order they were passed |
KeyValuePairs |
TArray<TPair<string, TValue>>; empty when the template names nothing |
TryGetValue(Key, out Value) |
the value of one named placeholder |
Formatter |
the TLogFormatter that turns the state into a line |
LogInformation('user {User} ordered {Count}', [Name, N]) yields two pairs, and TryGetValue is how a layout resolves {User} — it is what NNLog’s renderer calls. A template that names nothing yields no pairs, and Arguments still holds them in order, which is what a '%s' template uses.
TLogEntry
Single log entry delivered to a provider’s Write method.
TLogEntry = record
Level : TLogLevel;
Category : string;
EventId : TEventId;
Exception : Exception;
Scope : string;
State : TLogState;
Formatter : TLogFormatter;
end;Message is a read-only property, not a field: the first read runs Formatter over State and keeps the result. A provider that writes its own layout out of State never pays for it.
Message does not carry the scope. Scope holds the open scopes from the innermost to the outermost, each prefixed with |, and is empty outside any scope; a provider that wants them writes them from there.
Bootstrap channel
An ILogger available from initialization, with no dependency injection and no configuration. It covers the stretch before BuildServiceProvider, where nothing that was registered can report a failure yet — and only that stretch: no per-category levels, no configurable layouts, no asynchronous writing. It is not configured from a file, deliberately: reading configuration to find out where to report that reading the configuration failed is the same chicken and the same egg.
When to reach for it, what each deployment shape gets, and the handover to the pipeline are in Startup composition.
IBootstrapSink
A destination of the channel. It receives a line already formed — never a TLogEntry — so it cannot invent its own format nor filter: it sees nothing that lets it.
IBootstrapSink = interface(IInvokable)
function IsAvailable: Boolean;
procedure Write(const Line: string);
end;A destination that cannot write declares itself unavailable; it never takes a line it will lose in silence. IsAvailable is answered here and now, not once at registration: the error handle of a process without a console, or a path that stopped being writable, change the answer.
BootstrapLog
The channel itself: the registry of destinations and the dispatch. It never raises — a channel whose reason to exist is reporting a failure cannot cause a second one.
BootstrapLog = record
class procedure AddSink(const Sink: IBootstrapSink); static;
class procedure RemoveSink(const Sink: IBootstrapSink); static;
class procedure ClearSinks; static;
class function Sinks: TArray<IBootstrapSink>; static;
class function AvailableSinks: TArray<IBootstrapSink>; static;
class function HasAvailableSink: Boolean; static;
class procedure Write(const Line: string); static;
class function WasReported(const E: Exception): Boolean; static;
class function FormatLine(const Entry: TLogEntry): string; static;
class function LoggerFor(const Category: string): ILogger; static;
end;| Member | Answers |
|---|---|
AddSink RemoveSink ClearSinks |
Destinations are added and removed from initialization or from the main, before the host is built. Adding is additive: DAF’s own reports keep coming out through theirs |
Sinks |
Every registered destination |
AvailableSinks HasAvailableSink |
The ones that can write right now. Empty is the worst case — nobody is going to learn anything — and it has to be distinguishable from having reported |
Write |
Dispatches a line already formed to the available destinations |
WasReported |
Whether that exception already reached at least one destination. It answers for the last one that did, and recognises it by identity, class and message. A report the threshold discards, or one that finds no available destination, does not count |
FormatLine |
The layout of the channel, applied before dispatching so every destination writes the same thing |
LoggerFor |
A startup ILogger under the caller’s category: an application reports its own startup without disguising itself as DAF. It is the only way in, and DAF’s own reports go through it too |
The channel keeps a Warning-and-above threshold.
The destinations that live here
| Class | Writes to | Registered by default |
|---|---|---|
TDebugOutputBootstrapSink |
the system debug output — on Windows, OutputDebugString, which belongs to the system and not to the debugger, so it is captured with none attached |
yes |
TStdErrBootstrapSink |
the process’s error output. An application with no console has no error handle, and the destination declares itself unavailable there; it does not raise on write | yes |
TFileBootstrapSink |
the file named in its constructor, readable back from its FileName property |
no — a durable path is known by the application, not by the framework |
TPlatformLogBootstrapSink, over IFMXLoggingService, lives in the Daf.FMX package so that FireMonkey reaches only whoever references it — see Daf.FMX.
What lives here, and why
This is the contracts package, and yet it also holds concrete classes: TNullLogger, TNullLoggerFactory, TNullLoggerProvider, and the bootstrap channel destinations (TDebugOutputBootstrapSink, TStdErrBootstrapSink, TFileBootstrapSink). That looks like a contradiction, so here is the rule that decides where each piece of logging goes:
A package boundary is earned by isolating a dependency the others do not need.
The rule answers in both directions, and the two cases already settled are the examples:
| Piece | Dependency it drags in | Where it lives |
|---|---|---|
TStdErrBootstrapSink, TDebugOutputBootstrapSink, TFileBootstrapSink |
nothing beyond the RTL | here, with the contract |
TPlatformLogBootstrapSink |
FireMonkey | Daf.FMX — a package of its own |
TNullLogger, TNullLoggerFactory, TNullLoggerProvider |
nothing | here, with the contract |
A destination that needs nothing but the standard library earns no boundary; one that leans on a graphical framework does, which is why the FireMonkey sink has a package of its own. .NET puts NullLogger in Microsoft.Extensions.Logging.Abstractions for the same reason: a default with no dependencies that everyone needs.
There is a second reason that applies only to the bootstrap destinations: moving them to another package would deregister them by default. An application that today gets the error output and the system debug output without asking for anything would end up with no destination at all unless it added a reference — which is exactly the worst case BootstrapLog.HasAvailableSink exists to make visible.