Skip to content

Fluent Builder

LunarECL edited this page Feb 24, 2026 · 3 revisions

Fluent Builder

Declarative, self-documenting logger configuration. LunarLog::configure() returns a LoggerConfiguration builder — chain method calls to set levels, add sinks, register enrichers and filters, then call build() to get a ready-to-use LunarLog instance.

Overview

The imperative API configures a logger through sequential method calls on an already-constructed LunarLog object:

minta::LunarLog logger(minta::LogLevel::DEBUG, false);
logger.addSink<minta::ConsoleSink>(minta::named("console"));
logger.addSink<minta::FileSink, minta::JsonFormatter>(minta::named("json-out"), "app.json.log");
logger.sink("json-out").level(minta::LogLevel::INFO);
logger.enrich(minta::Enrichers::threadId());
logger.addFilterRule("not message contains 'heartbeat'");

The fluent builder expresses the same configuration as a single declaration:

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .writeTo<minta::ConsoleSink>("console")
    .writeTo<minta::FileSink, minta::JsonFormatter>("json-out",
        [](minta::SinkProxy& s) { s.level(minta::LogLevel::INFO); },
        "app.json.log")
    .enrich(minta::Enrichers::threadId())
    .filterRule("not message contains 'heartbeat'")
    .build();

Both produce identical loggers. The builder is an alternative style — the imperative API is unchanged and fully supported.

Why use the builder?

  • Self-documenting — the entire logger configuration is visible in one place
  • Declarative — describes what the logger should be, not how to build it step by step
  • Safebuild() enforces single-use semantics and validates the configuration
  • Inline sink configurationwriteTo with a lambda configures the sink in the same expression where it is created

Quick Start

#include "lunar_log.hpp"

int main() {
    auto logger = minta::LunarLog::configure()
        .minLevel(minta::LogLevel::INFO)
        .writeTo<minta::ConsoleSink>()
        .writeTo<minta::FileSink>("app.log")
        .build();

    logger.info("Server started on port {port}", "port", 8080);
    return 0;
}

Entry Point

static LoggerConfiguration LunarLog::configure()

Returns a new LoggerConfiguration builder with default settings:

Setting Default
Minimum level INFO
Source location capture false
Rate limit 1000 messages / 1000 ms
Template cache size 128
Locale "" (system default "C")
Enrichers none
Filters none
Sinks none
auto config = minta::LunarLog::configure();
// ... chain configuration ...
auto logger = config.build();

Or more commonly, chain everything:

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .writeTo<minta::ConsoleSink>()
    .build();

LoggerConfiguration Methods

All methods return LoggerConfiguration& for chaining. The builder is move-only (non-copyable).

Global Configuration

minLevel(LogLevel level)

Sets the minimum log level. Messages below this level are dropped before reaching any sink.

.minLevel(minta::LogLevel::DEBUG)

Maps to LunarLog::setMinLevel() in the imperative API.

captureSourceLocation(bool enable)

Enables or disables capture of file, line, and function for each log entry.

.captureSourceLocation(true)

Maps to LunarLog::setCaptureSourceLocation() in the imperative API.

rateLimit(size_t maxLogs, std::chrono::milliseconds window)

Sets the rate limit. Messages exceeding the limit within the window are silently dropped.

.rateLimit(5000, std::chrono::milliseconds(1000))  // 5000 msgs/sec

Default: 1000 messages per 1000 ms.

templateCacheSize(size_t size)

Sets the maximum number of parsed templates to cache. Set to 0 to disable caching.

.templateCacheSize(256)

Default: 128.

minLevel(std::shared_ptr<LevelSwitch> levelSwitch)

Sets a shared observable log level. The logger reads from this LevelSwitch on every log call. Changing the switch from any thread takes effect immediately.

auto levelSwitch = std::make_shared<minta::LevelSwitch>(minta::LogLevel::INFO);

auto logger = minta::LunarLog::configure()
    .minLevel(levelSwitch)
    .writeTo<minta::ConsoleSink>()
    .build();

// Later, from any thread:
levelSwitch->set(minta::LogLevel::DEBUG);  // takes effect immediately

Multiple loggers can share a single LevelSwitch. See Dynamic Configuration for details.

watchConfig(const std::string& path, std::chrono::seconds interval)

Watches a JSON configuration file for runtime changes. The file is polled at the given interval (checking mtime). On change, the config is parsed and applied atomically:

  • "minLevel": updates the global min level (or LevelSwitch if one is attached)
  • "sinks": updates per-sink levels by name
  • "filters": replaces global filter rules via COW

If the file is malformed, current settings are kept and a warning is logged.

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::INFO)
    .writeTo<minta::ConsoleSink>("console")
    .watchConfig("logging.json", std::chrono::seconds(5))
    .build();

See Dynamic Configuration for the JSON file format and details.

locale(const std::string& loc)

Sets the global locale for culture-specific formatting.

.locale("de_DE")

Maps to LunarLog::setLocale() in the imperative API.

Enrichers

enrich(EnricherFn fn)

Registers an enricher function that runs on every log entry. Call multiple times to register multiple enrichers — they run in registration order.

.enrich(minta::Enrichers::threadId())
.enrich(minta::Enrichers::processId())
.enrich(minta::Enrichers::machineName())
.enrich(minta::Enrichers::property("service", "auth-api"))
.enrich([](minta::LogEntry& entry) {
    entry.customContext["correlationId"] = generateId();
})

See Enrichers for the full guide on built-in and custom enrichers.

Filters

filter(const std::string& compact)

Adds compact filter rules using grep-inspired shorthand. Space-separated tokens are AND-combined.

.filter("WARN+ !~heartbeat ctx:env=prod")

See Compact Filter for syntax.

filterRule(const std::string& dsl)

Adds a DSL filter rule. Call multiple times — rules are AND-combined.

.filterRule("level >= WARN")
.filterRule("not message contains 'heartbeat'")

See Filtering for the full DSL syntax.

writeTo Variants

writeTo adds a sink to the builder. There are six overloads covering all combinations of naming, formatting, and inline configuration.

Unnamed Sink

Auto-named "sink_0", "sink_1", etc.

// Default formatter
.writeTo<minta::ConsoleSink>()

// With custom formatter
.writeTo<minta::FileSink, minta::JsonFormatter>("app.json.log")

// RollingFileSink with policy
.writeTo<minta::RollingFileSink>(
    minta::RollingPolicy::daily("logs/app.log").maxFiles(30))

Signature:

template<typename SinkType, typename... Args>
LoggerConfiguration& writeTo(Args&&... args);

template<typename SinkType, typename FormatterType, typename... Args>
LoggerConfiguration& writeTo(Args&&... args);

Args are forwarded to the SinkType constructor.

Named Sink

Give the sink a human-readable name for later reference.

// Default formatter
.writeTo<minta::ConsoleSink>("console")

// With custom formatter
.writeTo<minta::FileSink, minta::JsonFormatter>("json-out", "app.json.log")

Signature:

template<typename SinkType, typename... Args>
LoggerConfiguration& writeTo(const std::string& name, Args&&... args);

template<typename SinkType, typename FormatterType, typename... Args>
LoggerConfiguration& writeTo(const std::string& name, Args&&... args);

The first argument is the sink name; remaining args are forwarded to the constructor.

SFINAE note: The named overload is disabled when SinkType is constructible from (const std::string&, Args...) — this prevents the sink name from being silently consumed as a constructor argument. In practice, this means writeTo<FileSink>("my-sink", "file.log") works correctly: "my-sink" is the name, "file.log" goes to the FileSink constructor.

Named Sink with Lambda Configuration

Configure the sink inline using a SinkProxy& callback. This is the most powerful overload — it lets you set level, filters, locale, output template, and tag routing in the same expression where the sink is created.

.writeTo<minta::FileSink>("errors",
    [](minta::SinkProxy& s) {
        s.level(minta::LogLevel::ERROR)
         .outputTemplate("[{timestamp:HH:mm:ss}] [{level:u3}] {message}{exception}")
         .only("critical");
    }, "errors.log")

// With custom formatter
.writeTo<minta::FileSink, minta::CompactJsonFormatter>("pipeline",
    [](minta::SinkProxy& s) {
        s.level(minta::LogLevel::INFO)
         .filter("!~health !~ping");
    }, "logs/app.jsonl")

Signature:

template<typename SinkType, typename ConfigFn, typename... CtorArgs>
LoggerConfiguration& writeTo(const std::string& name, ConfigFn&& configure, CtorArgs&&... args);

template<typename SinkType, typename FormatterType, typename ConfigFn, typename... CtorArgs>
LoggerConfiguration& writeTo(const std::string& name, ConfigFn&& configure, CtorArgs&&... args);

The lambda receives a SinkProxy& — see the SinkProxy reference below for all available methods.

SinkProxy Reference (Builder Context)

When using writeTo with a lambda, the callback receives a SinkProxy& for configuring the sink before build() is called. All methods return SinkProxy& for chaining.

Method Description
level(LogLevel lvl) Set the sink's minimum log level
filterRule(const std::string& dsl) Add a DSL filter rule
filter(const std::string& compactExpr) Add compact filter rules (shorthand syntax)
filter(FilterPredicate pred) Set a predicate filter
locale(const std::string& loc) Set a per-sink locale
formatter(std::unique_ptr<IFormatter> f) Replace the formatter
outputTemplate(const std::string& templateStr) Set custom output format (HumanReadableFormatter only)
only(const std::string& tag) Add an only-tag for tag routing
except(const std::string& tag) Add an except-tag for tag routing
clearFilter() Remove the predicate filter
clearFilterRules() Remove all DSL filter rules
clearFilters() Remove predicate + DSL rules (not tag filters)
clearTagFilters() Remove all only/except tag filters
.writeTo<minta::FileSink, minta::JsonFormatter>("audit",
    [](minta::SinkProxy& s) {
        s.level(minta::LogLevel::INFO)
         .only("audit")
         .only("security")
         .filterRule("not message contains 'internal'")
         .locale("en_US");
    }, "audit.json.log")

This is the same SinkProxy class used by logger.sink("name") in the imperative API. See API Reference — SinkProxy for full details.

subLogger

subLogger adds a nested pipeline with its own independent filters, enrichers, and sinks. The sub-pipeline receives all log entries that pass the parent's global filter, then applies its own independent filters before dispatching to its own sinks. Sub-logger enrichers operate on a cloned copy of the entry and do not affect the parent.

Named Sub-Logger

.subLogger("errors", [](minta::SubLoggerConfiguration& sub) {
    sub.filter("ERROR+")
       .enrich(minta::Enrichers::property("pipeline", "error-alerts"))
       .writeTo<minta::FileSink>("error-log", "errors.log");
})

Unnamed Sub-Logger

.subLogger([](minta::SubLoggerConfiguration& sub) {
    sub.minLevel(minta::LogLevel::WARN)
       .writeTo<minta::FileSink>("warnings.log");
})

Signature:

template<typename ConfigFn>
LoggerConfiguration& subLogger(const std::string& name, ConfigFn&& configure);

template<typename ConfigFn>
LoggerConfiguration& subLogger(ConfigFn&& configure);

The lambda receives a SubLoggerConfiguration& with the following methods:

Method Description
enrich(EnricherFn fn) Add an enricher to the sub-pipeline (independent from parent)
filter(const std::string& compact) Add compact filter rules (evaluated inside write())
filterRule(const std::string& dsl) Add a DSL filter rule
filter(FilterPredicate pred) Set a predicate filter
minLevel(LogLevel level) Set the sub-pipeline's ISink-level gate (checked before write())
writeTo<SinkType>(...) Add a sink to the sub-pipeline (named and unnamed variants)

Filter layering: minLevel() is checked by the parent's dispatch loop via passesFilter() before write() is called. filter() / filterRule() / filter(predicate) are evaluated inside write() after the ISink gate has passed. Both layers must pass for an entry to reach the sub-sinks.

See Sub-Logger for the full guide.

build()

LunarLog build()

Constructs the configured LunarLog instance and returns it by move.

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .writeTo<minta::ConsoleSink>()
    .build();

Behavior:

  1. Creates a bare LunarLog (no default console sink)
  2. Applies all global settings (level, locale, rate limit, cache size, source location)
  3. Attaches LevelSwitch if provided via minLevel(shared_ptr<LevelSwitch>)
  4. Registers all enrichers (in order)
  5. Adds all compact filter expressions
  6. Adds all DSL filter rules
  7. Adds all sinks (named and unnamed), including SubLoggerSink entries from subLogger()
  8. Stores watchConfig parameters (watcher starts lazily on first log call)
  9. Returns the configured logger ready for use
  10. Returns the logger by move

Throws: std::logic_error if build() is called more than once on the same LoggerConfiguration.

auto config = minta::LunarLog::configure()
    .writeTo<minta::ConsoleSink>();

auto logger = config.build();   // OK
auto second = config.build();   // throws std::logic_error

No sinks warning: If no writeTo() calls were made, build() prints a warning to stderr and returns a "silent logger" that discards all messages:

[LunarLog] Warning: build() called with no sinks — logger will silently discard all messages.

Builder vs Imperative API

Both approaches produce identical loggers. Choose the style that fits your codebase.

When to Use the Builder

  • Application entry point — configure the logger once at startup in a single expression
  • Configuration-from-code — the entire logger setup is visible and self-documenting
  • Preventing post-init mutation — after build(), the LoggerConfiguration is consumed

When to Use the Imperative API

  • Dynamic configuration — add sinks or change settings based on runtime conditions (CLI flags, config files)
  • Gradual setup — configure the logger across multiple initialization phases
  • Runtime reconfiguration — change levels, filters, or context while the logger is running

Side-by-Side Comparison

Builder:

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::INFO)
    .captureSourceLocation(true)
    .enrich(minta::Enrichers::threadId())
    .enrich(minta::Enrichers::property("service", "payment-api"))
    .writeTo<minta::ConsoleSink>("console")
    .writeTo<minta::FileSink, minta::JsonFormatter>("json-out",
        [](minta::SinkProxy& s) {
            s.level(minta::LogLevel::WARN)
             .locale("de_DE");
        }, "app.json.log")
    .filterRule("not message contains 'heartbeat'")
    .build();

Imperative:

minta::LunarLog logger(minta::LogLevel::INFO, false);
logger.setCaptureSourceLocation(true);
logger.enrich(minta::Enrichers::threadId());
logger.enrich(minta::Enrichers::property("service", "payment-api"));
logger.addSink<minta::ConsoleSink>(minta::named("console"));
logger.addSink<minta::FileSink, minta::JsonFormatter>(minta::named("json-out"), "app.json.log");
logger.sink("json-out").level(minta::LogLevel::WARN).locale("de_DE");
logger.addFilterRule("not message contains 'heartbeat'");

Both are equivalent. The builder groups everything into one expression; the imperative API allows interleaving configuration with logic.

Integration with Enrichers

Register enrichers in the builder chain. They run in the order they are added, and follow the same precedence rules as the imperative API: enricher < setContext < scoped context.

auto logger = minta::LunarLog::configure()
    .captureSourceLocation(true)
    .enrich(minta::Enrichers::threadId())
    .enrich(minta::Enrichers::processId())
    .enrich(minta::Enrichers::machineName())
    .enrich(minta::Enrichers::environment())
    .enrich(minta::Enrichers::property("service", "order-api"))
    .enrich(minta::Enrichers::property("version", "2.1.0"))
    .enrich(minta::Enrichers::fromEnv("AWS_REGION", "region"))
    .enrich(minta::Enrichers::caller())
    .writeTo<minta::ConsoleSink>()
    .writeTo<minta::FileSink, minta::CompactJsonFormatter>("pipeline", "logs/app.jsonl")
    .build();

See Enrichers for built-in enrichers, custom enrichers, and precedence rules.

Integration with Exception Attachment

Exception attachment works identically on loggers created by the builder. All exception overloads (log.error(ex, ...), log.error(ex)) are available:

auto logger = minta::LunarLog::configure()
    .writeTo<minta::ConsoleSink>("console")
    .writeTo<minta::FileSink, minta::JsonFormatter>("json-out",
        [](minta::SinkProxy& s) {
            s.outputTemplate("[{timestamp:HH:mm:ss}] [{level:u3}] {message}\n{exception}");
        }, "events.json.log")
    .build();

try {
    riskyOperation();
} catch (const std::exception& ex) {
    logger.error(ex, "Operation failed for user {name}", "john");
}

See Exception Attachment for nested exception unwinding, formatter output, and the {exception} output template token.

Examples

Minimal Console Logger

auto logger = minta::LunarLog::configure()
    .writeTo<minta::ConsoleSink>()
    .build();

Multi-Sink with Enrichers

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .captureSourceLocation(true)
    .enrich(minta::Enrichers::threadId())
    .enrich(minta::Enrichers::processId())
    .enrich(minta::Enrichers::property("service", "auth-api"))
    .writeTo<minta::ConsoleSink>("console")
    .writeTo<minta::FileSink>("app-log", "app.log")
    .writeTo<minta::FileSink, minta::JsonFormatter>("json-out", "app.json.log")
    .build();

Per-Sink Configuration with Lambdas

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::TRACE)
    .writeTo<minta::ConsoleSink>("console",
        [](minta::SinkProxy& s) {
            s.level(minta::LogLevel::INFO)
             .outputTemplate("[{timestamp:HH:mm:ss}] [{level:u3}] {message}");
        })
    .writeTo<minta::FileSink>("errors",
        [](minta::SinkProxy& s) {
            s.level(minta::LogLevel::ERROR)
             .only("critical");
        }, "errors.log")
    .writeTo<minta::FileSink, minta::CompactJsonFormatter>("pipeline",
        [](minta::SinkProxy& s) {
            s.filter("!~health !~ping")
             .locale("en_US");
        }, "logs/app.jsonl")
    .build();

Rolling File Sink

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::INFO)
    .writeTo<minta::ConsoleSink>()
    .writeTo<minta::RollingFileSink>(
        minta::RollingPolicy::daily("logs/app.log")
            .maxSize(50 * 1024 * 1024)
            .maxTotalSize(500 * 1024 * 1024))
    .build();

Sub-Logger Pipeline

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .writeTo<minta::ConsoleSink>("console")
    .subLogger("errors", [](minta::SubLoggerConfiguration& sub) {
        sub.filter("ERROR+")
           .enrich(minta::Enrichers::property("pipeline", "error-alerts"))
           .writeTo<minta::FileSink>("error-log", "errors.log");
    })
    .subLogger("audit", [](minta::SubLoggerConfiguration& sub) {
        sub.filter("INFO+")
           .writeTo<minta::FileSink, minta::JsonFormatter>("audit-log", "audit.json");
    })
    .build();

Dynamic Level with Config Watcher

auto levelSwitch = std::make_shared<minta::LevelSwitch>(minta::LogLevel::INFO);

auto logger = minta::LunarLog::configure()
    .minLevel(levelSwitch)
    .writeTo<minta::ConsoleSink>("console")
    .writeTo<minta::FileSink, minta::JsonFormatter>("json-out", "app.jsonl")
    .watchConfig("logging.json", std::chrono::seconds(5))
    .build();

// Programmatic change (any thread):
levelSwitch->set(minta::LogLevel::DEBUG);

// Or edit logging.json — changes are picked up within 5 seconds

Production Service Setup

A realistic production configuration combining multiple sinks, enrichers, filters, and per-sink customization:

auto logger = minta::LunarLog::configure()
    .minLevel(minta::LogLevel::DEBUG)
    .captureSourceLocation(true)
    .templateCacheSize(512)
    .locale("en_US")

    // Enrichers — service identity and infrastructure metadata
    .enrich(minta::Enrichers::threadId())
    .enrich(minta::Enrichers::processId())
    .enrich(minta::Enrichers::machineName())
    .enrich(minta::Enrichers::environment())
    .enrich(minta::Enrichers::property("service", "payment-api"))
    .enrich(minta::Enrichers::property("version", "3.2.0"))
    .enrich(minta::Enrichers::fromEnv("AWS_REGION", "region"))

    // Global filters — suppress noise
    .filter("!~heartbeat !~health")

    // Console: human-readable, INFO+ for development
    .writeTo<minta::ConsoleSink>("console",
        [](minta::SinkProxy& s) {
            s.level(minta::LogLevel::INFO)
             .outputTemplate("[{timestamp:HH:mm:ss}] [{level:u3}] {message}");
        })

    // Compact JSON: pipeline output for ELK/Datadog/Loki
    .writeTo<minta::FileSink, minta::CompactJsonFormatter>("pipeline",
        [](minta::SinkProxy& s) {
            s.level(minta::LogLevel::INFO);
        }, "logs/service.jsonl")

    // Full JSON: audit trail, restricted to audit-tagged messages
    .writeTo<minta::FileSink, minta::JsonFormatter>("audit",
        [](minta::SinkProxy& s) {
            s.only("audit")
             .only("security");
        }, "logs/audit.json")

    // Rolling file: debug-level diagnostics with rotation
    .writeTo<minta::RollingFileSink>(
        minta::RollingPolicy::daily("logs/debug.log")
            .maxSize(50 * 1024 * 1024)
            .maxFiles(7))

    .build();

See also: API Reference — LoggerConfiguration, Enrichers, Exception Attachment, Cookbook

Clone this wiki locally