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.

pascal
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.

pascal
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.

pascal
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.

pascal
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.

pascal
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 duration

With the default pattern (times left out), ImportFile(Log, 'orders.csv') writes:

Example
[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.

pascal
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.

pascal
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.

pascal
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.

pascal
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.

pascal
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.

  1. Create a console application named FirstLogger and make the Atlas Log units reachable: either install the Atlas Log packages, or add the product's src folder 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.JSON is only needed for the checks.

    pascal
    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;
  2. Add two helpers. Check raises when a condition fails, which makes the program exit with code 1. ReadLines reads 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 with fmShareDenyNone.

    pascal
    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;
  3. 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 := 5 keeps up to five archives. Every sink is created with the logger as owner, so freeing the logger frees them and closes the files.

    pascal
    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;
  4. Log a short script: a plain event, a debug event with a property, two events on the orders channel inside a checkout scope, an exception through Failure, and twenty events inside a timed scope. Setting Scope and Timer to nil ends 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.

    pascal
        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;
  5. Check the memory sink. Snapshot returns the events oldest first, so the order event is at index 2, the failure at 4 and the timer's event last. PropertyValue finds a property by name; Template keeps the text before the holes were filled.

    pascal
        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');
  6. Check the text files. Rotation moved full files to app.1.log, app.2.log and so on, with app.1.log the most recent archive, so reading from the highest number down to the active file gives every line in order. No file may exceed MaxBytes, and the debug event must be missing because the file's MinLevel is llInfo. The exact lines show what the pattern does: empty tokens leave no double spaces, the scope is in brackets, the exception follows the message.

    pascal
        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]);
  7. 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 nested exception object. Freeing the logger at the end frees the sinks and closes both files.

    pascal
        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;
  8. 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.

    pascal
    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.
  9. Run the program. It prints an ok: line for each check, the last five text lines and one JSON line, and ends with All checks passed. and exit code 0. Your paths, thread id, timestamp and milliseconds differ:

    Example
    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 Folder at your application's log folder, raise MaxBytes to a realistic size such as 10 * 1024 * 1024, and try Plain.Daily := True to 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.