Skip to content

Performance and Internals

thushxr edited this page Jun 27, 2026 · 1 revision

Performance & Internals

How the logger works under the hood, and the design choices behind it. None of this is required reading to use the library — it's here for the curious and for anyone evaluating it for a hot path.

The write path

Every public method funnels into one shared internal method, WriteCore. The sequence:

  1. Snapshot options. The current LoggerOptions reference is read once into a local, so a concurrent Configure can't tear the read mid-entry.
  2. Level check first. If the entry is below MinimumLevel, it returns immediately — before any formatting, allocation, or string work. Suppressed logs are nearly free.
  3. Render the template (Log only) into the final message and collect (name, value) pairs for the structured fields.
  4. Resolve source fields. For Log<T>, class = typeof(T).Name and method = the [CallerMemberName] value. For Log, both are null.
  5. Build the output — either JSON (Utf8JsonWriter over a pooled-ish byte buffer) or text (StringBuilder).
  6. Write atomically. A single lock guards the Console.Out.WriteLine so concurrent threads never interleave their lines.

Source capture is free — no stack walk

Log<T> gets class/method context without inspecting the call stack:

  • Class comes from typeof(T).Name, resolved once per closed generic type and cached in a static readonly field. Log<OrderService> computes "OrderService" exactly once for the lifetime of the process.
  • Method comes from [CallerMemberName], which the compiler substitutes at the call site as a constant string. No runtime lookup.

This is why performance doesn't degrade as the call stack deepens — there's no StackTrace to walk. (A stack-walk approach, by contrast, gets slower the deeper you are and needs PDBs for detail.)

Why there are no line numbers

Line numbers were intentionally removed. Capturing them requires a StackTrace and PDB reads on every call — too costly for a hot path, and only as accurate as the symbols you actually deployed. Class and method give you the location you usually need, for free.

Allocations

  • Suppressed entries: essentially zero work after the level check.
  • Text mode: a StringBuilder (pre-sized to 160 chars) per entry.
  • JSON mode: an ArrayBufferWriter<byte> (pre-sized to 256 bytes) and a Utf8JsonWriter per entry, then one UTF-8 → string decode.
  • Template parsing: allocates a List for the (name, value) pairs only when the template actually contains placeholders and you passed args; otherwise it short-circuits and returns the template unchanged.
  • Log<T> category: allocated once per type, never per call. Empty args are a shared static readonly array.

These are deliberately modest, single-entry allocations — fine for typical logging volumes. For extreme throughput you'd batch or use a binary sink, which is out of scope for a zero-dependency console logger.

Thread safety

  • Writing is serialized by a lock, so each entry is atomic — lines from concurrent threads never interleave.
  • Configure swaps the options reference under the same lock; in-flight writes use the snapshot they already took.
  • Trace id is an AsyncLocal<string?>, so it's isolated per async flow — concurrent requests don't see each other's ids. See Trace Correlation.

Reserved keys

In JSON mode, the logger writes a fixed set of built-in fields:

timestamp · level · icon · traceId · class · method · message · exception

When emitting your template values as fields, any whose name collides with one of these is skipped, so user data can't overwrite a built-in field (e.g. a placeholder named level can't clobber the real log level — which matters because most log tools do last-wins on duplicate keys). The collision check is a small linear scan over the 8-item list; with the handful of placeholders a typical entry has, the cost is negligible. The skipped value is still present in the rendered message — it just doesn't get its own field. See Message Templates.

Output target

The logger writes to Console.Out (standard output). In containers, Lambda, and most cloud hosts, stdout is captured and shipped to your log system automatically, which is why a simple Console.Out sink is enough. JSON mode is designed to be one object per line for exactly this reason.

JSON escaping

The JSON writer uses JavaScriptEncoder.UnsafeRelaxedJsonEscaping, so <, >, &, and emoji are written literally instead of as \uXXXX. This keeps console output human-readable and is safe for stdout/log files — but is not meant for injecting directly into HTML.

Clone this wiki locally