YOUSEFUL / DELPHI
Atlas Log: FAQ, examples and tutorial
Learning center · Component reference
Atlas Log is a structured logging framework for Delphi. A TAtlasLogger takes events at six levels, fills message templates whose values also travel as named properties, tags each event with a category and the calling thread's scope, and hands it to sinks: a memory ring buffer, the console, the debugger, a rotating file, an event handler and an asynchronous wrapper for slow targets. TAtlasLogView shows a memory sink live in a VCL form. Add Atlas.Log (logger, events, channels) and Atlas.LogSinks (sinks) to uses, Atlas.LogFormat only when you format events yourself, and Atlas.LogView for the viewer. The core needs only the RTL (package AtlasLogRuntime), so it works in services and console programs; the viewer is in AtlasLogVclRuntime and needs the VCL. Atlas Log depends on no other Atlas product, and its components are on the Atlas Log palette page.
FAQ
Can a logging call raise an exception into my code? No. Every logging method catches everything: when a sink raises while writing or flushing, the logger adds one to SinkErrors, records 'ClassName: message' in LastSinkError, and still hands the event to the other sinks. A sink behind a TAtlasAsyncSink fails on the background thread, so it is counted in the asynchronous sink's TargetErrors instead. Configuration mistakes do raise EAtlasLogError at the point you make them: AddSink(nil), a blank category in SetCategoryLevel, an invalid Pattern, a negative MaxBytes or MaxFiles, a Capacity outside 1 to 10,000,000, or an asynchronous sink that targets itself.
Is it thread safe? Every public method is. The logger copies its sink list under a lock and writes outside it, so a slow sink never stalls another thread's level check. Each sink serializes its own writes, so DoWrite in a custom sink and a TAtlasCallbackSink handler never run twice at once, but they run on the thread that logged. Sequence numbers are unique per logger and each thread's events keep their order; the samples check this with four and the tests with eight threads.
How do levels and thresholds combine? The levels are llTrace, llDebug, llInfo, llWarning, llError and llFatal; llOff is a threshold only. An event is created only when it passes the logger: MinLevel (default llInfo), or for a category the threshold set with SetCategoryLevel, which covers that category and everything below it by whole dotted segments (db covers db.pool, not dbx), longest prefix first, ignoring case. Each sink then applies its own MinLevel (default llTrace). Use IsEnabled to skip building an expensive argument.
How do message templates work? {name} holes are filled from the arguments in order, by position, not by name, and each filled hole is also stored as a property (TAtlasLogProperty) on the event. {{ and }} are literal braces. A hole without an argument stays as written; arguments without a hole are kept as properties named arg plus their index. Values are formatted the same in every locale: 1250.5, true, nil, an exception as EClass: message, another object as its class name. There are no format specifiers: {total:0.00} is a hole named total:0.00, so format the value before passing it.
What is a channel? Log.Channel('orders') returns a TAtlasLogChannel, a small record that logs through that logger with the category orders. It costs nothing to create and is meant to be kept in a field. Categories are free text; dots make them hierarchical for SetCategoryLevel. A channel field that was never assigned does nothing when called.
What do scopes and timers do? BeginScope('import') names what the calling thread is doing until the returned interface is released; events logged meanwhile carry Scope = 'import', and nested scopes are joined with / (import/row 7). Scopes are per thread and never leak to other threads. Time('import') is a scope that, when released, also logs import finished in 12 ms with the properties scope and elapsed_ms, at llInfo unless you pass another level. Release a scope on the thread that began it.
How does the file sink rotate and name files? With FileName := 'logs\app.log' and MaxBytes set, the sink rolls before the write that would pass the limit: app.log becomes app.1.log, the previous app.1.log becomes app.2.log, and so on; an archive that would land beyond MaxFiles is deleted (0 keeps them all). One line longer than the limit gets a file to itself. With Daily, the active file is app.2026-09-21.log, named by the local date of each event, and MaxFiles keeps that many older files by modification time. The folder is created when needed. Lines are UTF-8 without a byte order mark, ending in CR LF, appended to an existing file.
Text or JSON? Text sinks (file, console, debugger) have Layout. loText uses Pattern, by default {time:yyyy-mm-dd hh:nn:ss.zzz} [{level:short}] {category} {scope} {message}{exception}{props}; a token that renders empty drops a neighbouring space, and line breaks in values are written as \n so a message cannot forge a log line. loJson writes one object per line with the keys ts (UTC, ISO 8601), seq, level, category, thread, scope, msg, template, exception and props, in that order, leaving out empty ones; property values are strings and repeated names become name_2.
When do I need the asynchronous sink? When a sink is slower than the code that logs. TAtlasAsyncSink queues events and writes them to its Target on its own thread. The queue holds Capacity events (default 10,000); when it is full, Overflow decides: ofBlock (default) makes the logging thread wait, ofDropNewest discards the incoming event, ofDropOldest discards the oldest queued one, and both count losses in Dropped. Flush waits until the queue is written, and destroying the sink writes what is still queued.
Can I write my own sink? Yes. Derive from TAtlasLogSink and override DoWrite (and DoFlush if the output buffers); both are called under the sink's lock, so they need no locking of their own, and they may raise, because the logger counts the failure. For a sink that writes text lines, derive from TAtlasTextSink and call its protected Line(Event), which applies Layout and Pattern.
How do I show the log in a VCL application? Attach a TAtlasMemorySink to the logger and point a TAtlasLogView at it. The view is a virtual list: it keeps a filtered snapshot and draws only the visible rows, in level colours, with MinLevel and FilterText filters, and follows the newest row unless you select one further up. Events may be logged from any thread; the view notices changes through the sink's Version on a main-thread timer (RefreshInterval, 200 ms by default).
What is not implemented? Network sinks (syslog, HTTP, TCP), a Windows Event Log sink and e-mail; configuration from a file and a runtime reconfiguration interface (components are configured in code or the form designer); stack traces and call-site capture (unit, line); compression of archives and retention by total size or age; enrichers (machine, process, user) and correlation identifiers across threads (scopes are per thread); viewers for anything but the VCL, and a remote live viewer. Holes have no format specifiers, and there is no API for reading log files back.
Examples
A logger that writes to the console and to a size-rotated file. Sinks created with the logger as owner are freed with it.
uses System.SysUtils, Atlas.Log, Atlas.LogSinks;
procedure LogToConsoleAndFile;
var
Log: TAtlasLogger;
Console: TAtlasConsoleSink;
Files: TAtlasFileSink;
begin
Log := TAtlasLogger.Create(nil);
try
Console := TAtlasConsoleSink.Create(Log);
Console.Logger := Log;
Files := TAtlasFileSink.Create(Log);
Files.FileName := 'logs\app.log'; // relative to the current directory
Files.MaxBytes := 5 * 1024 * 1024;
Files.MaxFiles := 10;
Files.Logger := Log;
Log.Info('Order {id} placed by {customer}', [1042, 'Contoso']);
finally
Log.Free;
end;
end;The console (in a console program) and logs\app.log both get the line
2026-09-21 14:03:07.412 [INF] Order 1042 placed by Contoso {id=1042, customer=Contoso}.
The shared logger, AtlasLog, is created on first use and freed when the program ends. Here it feeds the debugger.
uses Atlas.Log, Atlas.LogSinks;
var
Debugger: TAtlasDebugSink;
procedure StartLogging;
begin
Debugger := TAtlasDebugSink.Create(nil);
Debugger.Logger := AtlasLog;
AtlasLog.MinLevel := llDebug;
AtlasLog.Debug('Cache holds {n} entries', [128]);
end;
procedure StopLogging;
begin
Debugger.Free; // leaves the logger again
end;Run under the debugger, the IDE's event log shows ... [DBG] Cache holds 128 entries {n=128}.
Category thresholds let one area log in detail while the rest stays quiet. The longest configured prefix decides, by whole segments.
uses Atlas.Log;
procedure Thresholds(Log: TAtlasLogger);
var
Db: TAtlasLogChannel;
begin
Log.MinLevel := llWarning;
Log.SetCategoryLevel('db', llDebug);
Log.SetCategoryLevel('db.pool', llError);
Db := Log.Channel('db.query');
Db.Debug('Query took {ms} ms', [12]); // logged: 'db' decides
Log.Channel('db.pool.idle').Warning('Pool is idle'); // dropped: 'db.pool' decides
Log.Channel('dbx').Info('Not under db'); // dropped: MinLevel decides
if Log.IsEnabled(llDebug, 'db.plan') then
Log.Channel('db.plan').Debug('Plan: {plan}', ['index scan']); // logged
end;Two events are logged: Query took 12 ms and Plan: index scan, both in categories under db.
Failure keeps the exception's class and message apart from your text. It takes no arguments, so log values in a separate event.
uses System.SysUtils, Atlas.Log;
procedure ArchiveOrder(Log: TAtlasLogger; OrderId: Integer);
var
Orders: TAtlasLogChannel;
begin
Orders := Log.Channel('orders');
try
raise EInOutError.Create('Disk quota exceeded');
except
on E: Exception do
begin
Orders.Failure(E, 'Could not archive the order');
Orders.Warning('Order {id} will be archived again tonight', [OrderId]);
end;
end;
end;With the default pattern the first line ends [ERR] orders Could not archive the order | EInOutError: Disk quota exceeded; in JSON it has "exception":{"type":"EInOutError","message":"Disk quota exceeded"}.
A timed scope around an import, with a scope per row inside it.
uses System.SysUtils, Atlas.Log;
procedure ImportFile(Log: TAtlasLogger; const FileName: string);
var
Timer, Row: IInterface;
I: Integer;
begin
Timer := Log.Time('import');
Log.Info('Reading {file}', [FileName]);
for I := 1 to 2 do
begin
Row := Log.BeginScope('row ' + IntToStr(I));
Log.Info('Row read');
Row := nil; // ends the inner scope
end;
end; // Timer is released here and logs the durationWith the default pattern (times left out), ImportFile(Log, 'orders.csv') writes:
[INF] [import] Reading orders.csv {file=orders.csv}
[INF] [import/row 1] Row read
[INF] [import/row 2] Row read
[INF] [import] import finished in 3 ms {scope=import, elapsed_ms=3}One JSON file per day, keeping two weeks of history.
uses Atlas.Log, Atlas.LogSinks;
function AddDailyJson(Log: TAtlasLogger): TAtlasFileSink;
begin
Result := TAtlasFileSink.Create(Log);
Result.FileName := 'C:\ProgramData\MyApp\logs\events.jsonl';
Result.Daily := True; // events.2026-09-21.jsonl, by the local date of each event
Result.MaxFiles := 14; // older files beyond fourteen are deleted
Result.Layout := loJson;
Result.Logger := Log;
end;Each line is one object, for example
{"ts":"2026-09-21T12:03:07.412Z","seq":1,"level":"info","thread":8120,"msg":"Service started"}.
A file written on a background thread. Create the target first: an owner frees its components newest first, so the asynchronous sink is freed, and writes its queue, before its target goes.
uses Atlas.Log, Atlas.LogSinks;
procedure AddBackgroundFile(Log: TAtlasLogger);
var
Files: TAtlasFileSink;
Async: TAtlasAsyncSink;
begin
Files := TAtlasFileSink.Create(Log);
Files.FileName := 'logs\app.log'; // the target: not attached to the logger itself
Async := TAtlasAsyncSink.Create(Log);
Async.Capacity := 50000;
Async.Overflow := ofDropOldest; // never make the program wait; count losses instead
Async.Target := Files;
Async.Logger := Log;
end;Log.Info now returns as soon as the event is queued; Log.Flush waits until the file has everything, and Async.Dropped says how many events a full queue discarded.
A custom sink that counts events per level. DoWrite runs under the sink's own lock.
uses Atlas.Log;
type
TLevelCounter = class(TAtlasLogSink)
protected
procedure DoWrite(const Event: TAtlasLogEvent); override;
public
Counts: array[TAtlasLogLevel] of Integer;
end;
procedure TLevelCounter.DoWrite(const Event: TAtlasLogEvent);
begin
Inc(Counts[Event.Level]);
end;After Counter.Logger := Log, Counter.Counts[llError] is the number of errors logged since.
A live view on a form, over a memory sink that keeps the last 5,000 events.
uses Vcl.Forms, Vcl.Controls, Atlas.Log, Atlas.LogSinks, Atlas.LogView;
procedure AddLogView(Form: TForm; Log: TAtlasLogger);
var
Memory: TAtlasMemorySink;
View: TAtlasLogView;
begin
Memory := TAtlasMemorySink.Create(Form);
Memory.Capacity := 5000;
Memory.Logger := Log;
View := TAtlasLogView.Create(Form);
View.Parent := Form;
View.Align := alClient;
View.MinLevel := llInfo;
View.Sink := Memory;
end;The form shows one row per event at info and above, such as 14:03:07.412 WRN orders [checkout] Stock for AX-7 is low: 3 left {sku=AX-7, left=3}, and new rows appear within 200 ms from any thread.
Tests can replace the clock, for example to cross midnight for Daily files.
uses System.SysUtils, System.DateUtils, Atlas.Log;
var
FakeNow: TDateTime;
procedure UseFakeClock;
begin
FakeNow := EncodeDateTime(2026, 3, 4, 23, 59, 0, 0); // UTC
SetAtlasLogClock(function: TDateTime begin Result := FakeNow; end);
end;
procedure UseRealClock;
begin
SetAtlasLogClock(nil);
end;Events logged after UseFakeClock have TimeUtc equal to FakeNow; set the clock while no other thread is logging.
Tutorial
Sample project: FirstLogger.
Goal: set up a logger with a memory sink, a rotating text file and a JSON file, log with templates, a channel, a scope, a failure and a timer, and check the output by reading it back.
Create a console application named
FirstLoggerand make the Atlas Log units reachable: either install the Atlas Log packages, or add the product'ssrcfolder to the project's search path. Replace the whole program text with the steps below, in order. The first part names the program and its units;System.JSONis only needed for the checks.program FirstLogger; // Atlas Log quick start. One logger writes to a memory ring buffer, the // console (warnings and above), the debugger, a size-rotated text file and a // JSON-lines file, both files in a temporary folder. The program logs with // message templates, a channel, a scope, a failure and a timer, reads its own // output back, checks it, and deletes the folder. Exit code 0 means every // check passed. {$APPTYPE CONSOLE} uses System.SysUtils, System.Classes, System.IOUtils, System.JSON, Atlas.Log, Atlas.LogSinks;Add two helpers.
Checkraises when a condition fails, which makes the program exit with code 1.ReadLinesreads a log file while the file sink still has it open: the sink denies other writers but not readers, so the file must be opened withfmShareDenyNone.procedure Check(Condition: Boolean; const What: string); begin if not Condition then raise Exception.Create('Check failed: ' + What); Writeln(' ok: ', What); end; // The file sink keeps its file open and denies other writers, not readers. function ReadLines(const FileName: string): TArray<string>; var Stream: TFileStream; Bytes: TBytes; begin Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyNone); try SetLength(Bytes, Stream.Size); if Length(Bytes) > 0 then Stream.ReadBuffer(Bytes[0], Length(Bytes)); finally Stream.Free; end; Result := TEncoding.UTF8.GetString(Bytes).Split([sLineBreak], TStringSplitOptions.ExcludeEmpty); end;Create the logger and its sinks. The logger lets debug events through (
MinLevel := llDebug); the console sink shows only warnings and above, and the text file only info and above. The text file rolls at 512 bytes so that you can watch rotation with a handful of events;MaxFiles := 5keeps up to five archives. Every sink is created with the logger as owner, so freeing the logger frees them and closes the files.procedure Run(const Folder: string); var Log: TAtlasLogger; Memory: TAtlasMemorySink; Console: TAtlasConsoleSink; Debugger: TAtlasDebugSink; Plain, Json: TAtlasFileSink; Orders: TAtlasLogChannel; Scope, Timer: IInterface; Events: TArray<TAtlasLogEvent>; Lines: TArray<string>; Name: string; Parsed: TJSONValue; I, Archive, Archives: Integer; begin Log := TAtlasLogger.Create(nil); try Log.MinLevel := llDebug; // Sinks owned by the logger are freed with it. Memory := TAtlasMemorySink.Create(Log); Memory.Capacity := 100; Memory.Logger := Log; Console := TAtlasConsoleSink.Create(Log); Console.MinLevel := llWarning; Console.Pattern := ' console: [{level:short}] {category} {message}{exception}'; Console.Logger := Log; Debugger := TAtlasDebugSink.Create(Log); Debugger.Logger := Log; Plain := TAtlasFileSink.Create(Log); Plain.FileName := TPath.Combine(Folder, 'app.log'); Plain.Pattern := '[{level:short}] {category} {scope} {message}{exception}{props}'; Plain.MinLevel := llInfo; Plain.MaxBytes := 512; Plain.MaxFiles := 5; Plain.Logger := Log; Json := TAtlasFileSink.Create(Log); Json.FileName := TPath.Combine(Folder, 'app.jsonl'); Json.Layout := loJson; Json.Logger := Log;Log a short script: a plain event, a debug event with a property, two events on the
orderschannel inside acheckoutscope, an exception throughFailure, and twenty events inside a timed scope. SettingScopeandTimertonilends them; the timer logs its duration at that moment. When you run the program, the warning and the error appear on the console as they happen.Writeln('Logging...'); Log.Info('Application started'); Log.Debug('Settings read from {file}', ['settings.json']); Orders := Log.Channel('orders'); Scope := Log.BeginScope('checkout'); Orders.Info('Order {id} placed by {customer} for {total}', [1042, 'Contoso', 1250.5]); Orders.Warning('Stock for {sku} is low: {left} left', ['AX-7', 3]); Scope := nil; try raise EInOutError.Create('Disk quota exceeded'); except on E: Exception do Orders.Failure(E, 'Could not archive the order'); end; Timer := Log.Time('import'); for I := 1 to 20 do Log.Info('Imported row {row} of {rows}', [I, 20]); Timer := nil; Log.Flush;Check the memory sink.
Snapshotreturns the events oldest first, so the order event is at index 2, the failure at 4 and the timer's event last.PropertyValuefinds a property by name;Templatekeeps the text before the holes were filled.Writeln('Memory sink:'); Events := Memory.Snapshot; Check(Length(Events) = 26, '26 events reached the memory sink, the debug event included'); Check((Events[2].Message = 'Order 1042 placed by Contoso for 1250.5') and (Events[2].Category = 'orders') and (Events[2].Scope = 'checkout') and (Events[2].PropertyValue('customer') = 'Contoso') and (Events[2].Template = 'Order {id} placed by {customer} for {total}'), 'the template filled its holes and kept them as properties'); Check((Events[4].Level = llError) and (Events[4].ExceptionClass = 'EInOutError') and (Events[4].ExceptionMessage = 'Disk quota exceeded'), 'the failure kept the exception apart from the message'); Check((Events[5].Scope = 'import') and (Events[25].Message.StartsWith('import finished in ')) and (StrToIntDef(Events[25].PropertyValue('elapsed_ms'), -1) >= 0), 'the timer named the scope and reported its duration'); for I := 1 to High(Events) do if Events[I].Sequence <> Events[I - 1].Sequence + 1 then Check(False, 'sequence numbers increase by one'); Check(Log.SinkErrors = 0, 'no sink failed');Check the text files. Rotation moved full files to
app.1.log,app.2.logand so on, withapp.1.logthe most recent archive, so reading from the highest number down to the active file gives every line in order. No file may exceedMaxBytes, and the debug event must be missing because the file'sMinLevelisllInfo. The exact lines show what the pattern does: empty tokens leave no double spaces, the scope is in brackets, the exception follows the message.Writeln('Text file ', Plain.ActiveFileName, ':'); // Oldest archive first, then the active file. Lines := nil; Archives := 0; for Archive := Plain.MaxFiles downto 1 do begin Name := TPath.Combine(Folder, Format('app.%d.log', [Archive])); if not TFile.Exists(Name) then Continue; Inc(Archives); if TFile.GetSize(Name) > Plain.MaxBytes then Check(False, Name + ' stays within MaxBytes'); Lines := Lines + ReadLines(Name); end; Lines := Lines + ReadLines(Plain.ActiveFileName); Check(Archives >= 1, Format('full files were rolled into %d archives', [Archives])); Check(Length(Lines) = 25, 'all 25 events at info and above are in the text files, the debug event is not'); Check(Lines[0] = '[INF] Application started', 'empty tokens leave no double spaces'); Check(Lines[1] = '[INF] orders [checkout] Order 1042 placed by Contoso for 1250.5 {id=1042, customer=Contoso, total=1250.5}', 'category, scope and properties are on the line'); Check(Lines[3] = '[ERR] orders Could not archive the order | EInOutError: Disk quota exceeded', 'the exception follows the message'); Check(Lines[24].StartsWith('[INF] [import] import finished in '), 'the newest line is last in the active file'); for I := 20 to 24 do Writeln(' ', Lines[I]);Check the JSON file: one object per event, each parsed back with
System.JSON. The order event keeps message, template, category, scope and properties in separate members, and the failure has a nestedexceptionobject. Freeing the logger at the end frees the sinks and closes both files.Writeln('JSON file ', Json.ActiveFileName, ':'); Lines := ReadLines(Json.ActiveFileName); Check(Length(Lines) = 26, 'one JSON object per event'); for I := 0 to High(Lines) do begin Parsed := TJSONObject.ParseJSONValue(Lines[I]); if not (Parsed is TJSONObject) then Check(False, 'every line parses as a JSON object'); Parsed.Free; end; Writeln(' ', Lines[2]); Parsed := TJSONObject.ParseJSONValue(Lines[2]); try Check((Parsed.GetValue<string>('msg') = 'Order 1042 placed by Contoso for 1250.5') and (Parsed.GetValue<string>('category') = 'orders') and (Parsed.GetValue<string>('scope') = 'checkout') and (Parsed.GetValue<string>('template') = 'Order {id} placed by {customer} for {total}') and (Parsed.GetValue<string>('props.total') = '1250.5') and Parsed.GetValue<string>('ts').EndsWith('Z'), 'the JSON object keeps message, template, scope and properties apart, with a UTC time'); finally Parsed.Free; end; Parsed := TJSONObject.ParseJSONValue(Lines[4]); try Check((Parsed.GetValue<string>('level') = 'error') and (Parsed.GetValue<string>('exception.type') = 'EInOutError'), 'the exception is a nested object'); finally Parsed.Free; end; finally // Frees the sinks too, which closes the files. Log.Free; end; end;Finish with the main block. It uses a folder under
%TEMP%, runs everything, always deletes the folder, and turns any failed check into exit code 1.var Folder: string; begin Folder := TPath.Combine(TPath.GetTempPath, 'AtlasLogFirstLogger'); try try if TDirectory.Exists(Folder) then TDirectory.Delete(Folder, True); Run(Folder); Writeln('All checks passed.'); finally if TDirectory.Exists(Folder) then TDirectory.Delete(Folder, True); end; except on E: Exception do begin Writeln(E.ClassName, ': ', E.Message); ExitCode := 1; end; end; end.Run the program. It prints an
ok:line for each check, the last five text lines and one JSON line, and ends withAll checks passed.and exit code 0. Your paths, thread id, timestamp and milliseconds differ:Logging... console: [WRN] orders Stock for AX-7 is low: 3 left console: [ERR] orders Could not archive the order | EInOutError: Disk quota exceeded Memory sink: ok: 26 events reached the memory sink, the debug event included ... Text file C:\Users\you\AppData\Local\Temp\AtlasLogFirstLogger\app.log: ok: full files were rolled into 2 archives ... [INF] [import] Imported row 20 of 20 {row=20, rows=20} [INF] [import] import finished in 2 ms {scope=import, elapsed_ms=2} JSON file C:\Users\you\AppData\Local\Temp\AtlasLogFirstLogger\app.jsonl: ok: one JSON object per event {"ts":"2026-09-22T01:37:43.606Z","seq":3,"level":"info","category":"orders","thread":52800,"scope":"checkout","msg":"Order 1042 placed by Contoso for 1250.5","template":"Order {id} placed by {customer} for {total}","props":{"id":"1042","customer":"Contoso","total":"1250.5"}} ok: the JSON object keeps message, template, scope and properties apart, with a UTC time ok: the exception is a nested object All checks passed.To adapt it, keep step 3 and point
Folderat your application's log folder, raiseMaxBytesto a realistic size such as10 * 1024 * 1024, and tryPlain.Daily := Trueto get one file per day. The threaded asynchronous logging sample continues with threads and a background writer, and the live log viewer shows the events in a form.
Troubleshooting
Debug and trace events never appear. The logger's MinLevel is llInfo by default, so lower levels are never created. Set Log.MinLevel := llTrace, or lower the threshold for one area with SetCategoryLevel('db', llTrace). Each sink and each TAtlasLogView also has its own MinLevel.
One sink receives nothing while the others work. A sink that raises is skipped and counted, not reported to your code. Look at Log.SinkErrors and Log.LastSinkError; a file sink without a FileName reports TAtlasFileSink: The file sink has no FileName. For a sink behind a TAtlasAsyncSink, look at the asynchronous sink's TargetErrors. Also check that the sink's Logger is set: creating a sink does not attach it.
{id} appears literally in the message. There were fewer arguments than holes, and holes without an argument stay as written. Failure takes no arguments at all, so its holes are never filled; log the values in a separate event, or pass the exception as an argument (Log.Error('Order {id} failed: {error}', [Id, E])), which renders it as EClass: message but does not fill the exception fields. A hole such as {total:0.00} is filled but not formatted; format the value yourself.
Another program cannot open the log file, or TFile.ReadAllText raises "being used by another process". The file sink keeps the file open and denies other writers. Readers must allow writing: open it with TFileStream.Create(Name, fmOpenRead or fmShareDenyNone). TFile.ReadAllText asks to deny writers and therefore fails while the sink holds the file; call the sink's Close first (the next event reopens and appends).
The last events are missing from a file written through TAtlasAsyncSink. They were still queued. Call Log.Flush before the program ends, or free the asynchronous sink, which writes its queue first. If the target is freed before the asynchronous sink, the queue has nowhere to go: create the target first when both have the same owner, or free the asynchronous sink explicitly. Events still queued when the process is killed are lost.
Every event is written twice. The target of a TAtlasAsyncSink is also attached to the logger. Set only the asynchronous sink's Logger; leave the target's Logger empty.
Logging threads stall, or Flush never returns. With Overflow := ofBlock, a full queue makes logging threads wait for the target, and Flush waits until the queue is empty, however long the target takes. A TAtlasCallbackSink handler that blocks has the same effect on the thread that logged. Use ofDropNewest or ofDropOldest where the program must never wait, and keep handlers short. Do not log to the same logger from inside a handler: the event would come back to it.
Times show dots instead of colons, or the date separator differs. The {time:mask} and {utc:mask} tokens use FormatDateTime with the program's format settings, where : stands for the locale's time separator and / for its date separator. Quote them for fixed output, for example {time:hh":"nn":"ss}. Message values and JSON timestamps are not affected.
The program crashes or hangs when it closes while threads are logging. Threads that still log after the logger is freed use a dead object. Wait for your threads (WaitFor) before freeing the logger or the form that owns it; the viewer sample does this in its destructor.
Setting Pattern raises EAtlasLogError. The pattern has an unknown token (only time, utc, level, level:short, seq, thread, category, scope, message, exception and props exist), an unterminated {, or a single }; write {{ and }} for literal braces. The previous pattern stays in force.