Sign in Join

Live chat for Free Pascal, Lazarus, Delphi and every Pascal programmer.

Catalog · How the catalog works

Random project

delphi-concurrent-log

A thread-safe logging library for Delphi, with a portable core tested under Free Pascal in CI.

View on GitHub

Anyone may press Interesting, signed in or not. It helps others find this project.

Member rating

No ratings yet.

Sign in to rate this project.

README

ConcurrentLog

A small thread-safe logging library for Delphi.

Many threads can log at once without tearing each other's output. One lock, in one place; sinks and callers never lock. The core is plain Object Pascal with no platform or UI dependency, so it compiles under both Delphi and Free Pascal — which is what lets the test suite, including the concurrency test, run in CI.

Quick start

pascal
git clone https://github.com/ramonruanxc/delphi-concurrent-log.git

(or download the ZIP from GitHub and extract it).

  • Delphi XE7 or later: open demo/Demo.dpr and press F9. Nothing to configure: no search path, no defines, no project options. The console window stays open until you press Enter.

  • Free Pascal 3.2.2: from the repository root, run

    pascal
    fpc demo/Demo.dpr

    then demo\Demo.exe on Windows or ./demo/Demo on Linux.

Three threads log at once; the run ends with the path of the log file it wrote (next to the executable) and a single verdict line:

pascal
2026-09-23 13:18:45.178 [INFO ] (46884) starting three workers
2026-09-23 13:18:45.179 [INFO ] (61636) importer: step 1 of 5
...
2026-09-23 13:18:45.260 [INFO ] (46884) all workers finished

Log file: <clone>\demo\demo.log
SUCCESS: 20 lines from 3 threads, none lost or torn, Debug line filtered out.

Compiler support, honestly stated: Free Pascal 3.2.2 is verified in CI (Linux) and locally (Windows, i386). Delphi XE7 and later is the intended target but has not been compiled here: Delphi cannot run on a hosted CI runner, and the Community Edition refuses command line builds. The Delphi code paths avoid anything newer than XE7.

Usage

pascal
uses
  ConcurrentLog.Types, ConcurrentLog.Sinks, ConcurrentLog.Logger;

var
  Logger: TLogger;
begin
  Logger := TLogger.Create(llInfo);
  try
    Logger.AddSink(TConsoleSink.Create);
    Logger.AddSink(TFileSink.Create('app.log'));

    Logger.Info('service started');
    Logger.Log(llError, 'request %d failed: %s', [id, reason]);
  finally
    Logger.Free;
  end;
end;

Execution flow

Execution flow of delphi-concurrent-log

TLogger.Log acquires one lock, filters by level, builds an entry and writes to configured sinks serially before releasing the lock. Logging is synchronous; slow sinks hold up callers.

Install

With Boss:

pascal
boss install github.com/ramonruanxc/delphi-concurrent-log

Or add src to your project's search path. Targets Delphi XE7 or later (intended, not compiler-verified; see Quick start); the core also builds on Free Pascal 3.2.2, which CI verifies.

Concepts

Logger. Holds the lock, the sink list, and the minimum level. Create one, add sinks, log to it from any thread.

Level. llTrace < llDebug < llInfo < llWarn < llError. Entries below the logger's MinLevel are dropped. MinLevel is readable and writable at runtime from any thread.

Sink. A destination — TConsoleSink, TFileSink, TMemorySink. Implement ILogSink for your own. A sink never has to lock: the logger calls it from one thread at a time.

pascal
type
  ILogSink = interface
    procedure Write(const AEntry: TLogEntry);
  end;

TMemorySink keeps entries in memory and hands back a copy through Snapshot — useful in tests and for showing recent activity in a UI.

The design decision

The lock lives in the logger and nowhere else. Everything a log entry touches between "someone called Log" and "every sink has seen it" — the level check, building the entry, dispatching to each sink — happens inside one critical section.

That is deliberate. It is the smallest region that can be correct, and the only one that has to be. A sink author cannot introduce a data race, because they never touch shared state without the logger's lock held. The alternative — each sink locking itself — is more code, more locks, and more ways to get it wrong, for no benefit.

The cost is that sinks run serially while the lock is held, so a slow sink slows every logging thread. If you need to log across a slow sink without blocking callers, put a queue in front of it — a sink that hands entries to a background writer thread. That is a natural extension and intentionally left out of the core.

A note on lifetimes

Sinks are reference-counted interfaces (ILogSink). The logger holds them, so you do not free them yourself — construct and hand them straight to AddSink:

pascal
Logger.AddSink(TFileSink.Create('app.log'));

The logger itself is a plain object; free it when you are done, and it releases its sinks.

The global logger

For code that does not want to pass a logger around, GlobalLog returns a process-wide instance:

pascal
uses ConcurrentLog.Logger;

GlobalLog.AddSink(TConsoleSink.Create);   // once, at startup
GlobalLog.Info('ready');

It is created before any user code runs and destroyed at shutdown, so there is no lazy-initialisation race. It starts with no sinks and the default llInfo minimum.


Building from a clone

Every .dpr lists every unit it uses from this repository, directly or indirectly, with an explicit in '...' path relative to the .dpr, so opening one in the Delphi IDE and pressing F9 works with nothing to configure — no search path, no library path, no defines. Under the debugger the program waits for Enter before closing; run anywhere else, it never pauses.

Free Pascal resolves in paths from the current directory rather than from the .dpr, so each .dpr also carries an FPC-only {$UNITPATH ../src}. That makes a plain fpc demo/Demo.dpr or fpc tests/Tests.dpr work from the repository root with no options. Compiled units (.ppu, .o) land next to their sources and are git-ignored.

Demo

demo/Demo.dpr is a short console program: three threads logging at once through a console sink and a file sink, with a level filter dropping the quietest line. Run it and the interleaved output stays intact. It then re-reads its log file (demo.log, next to the executable), checks that every expected line is there and whole, and prints SUCCESS: ... (exit code 0) or FAILURE: ... (exit code 1). It takes no arguments.

pascal
fpc demo/Demo.dpr
./demo/Demo            # demo\Demo.exe on Windows

Running the tests

In Delphi, open tests/Tests.dpr and press F9. With Free Pascal, from the repository root:

pascal
fpc tests/Tests.dpr
./tests/Tests          # tests\Tests.exe on Windows

CI builds the same program with explicit -Fu/-FU options into build/; see .github/workflows/ci.yml.

The runner exits non-zero if any assertion fails. It uses a small assertion runner rather than DUnitX, because the suite has to run under both compilers and DUnitX does not run under Free Pascal. The assertions hold no framework state, so they can be wrapped in DUnitX for IDE use without changes.

Proving the lock earns its keep

A concurrency test that always passes proves nothing — it has to fail when the thing it tests is removed. This one does. Build it with the lock compiled out:

pascal
mkdir -p build/race
fpc -B -dPROVE_RACE -FUbuild/race -obuild/TestsRace tests/Tests.dpr
./build/TestsRace

-B and the separate build/race directory matter: FPC does not treat a changed define as a reason to recompile, so without them the race build could silently reuse units compiled with the lock.

Without the lock, eight threads calling TList.Add at once corrupt the backing array, and the run dies in a storm of access violations instead of reporting 40000 entries. That failure is the evidence that the passing run means something. PROVE_RACE is never defined by a normal build.


Licence

MIT. See LICENSE.