Skip to content

Structured Output

LunarEC's AI edited this page Feb 18, 2026 · 2 revisions

Structured Output

LunarLog's JSON and XML formatters produce structured log entries with the raw template, a hash for grouping, and typed properties.

JSON Formatter

minta::LunarLog logger(minta::LogLevel::INFO, false);
logger.addSink<minta::FileSink, minta::JsonFormatter>("app.json");

logger.info("User {username} logged in from {ip}", "username", "Alice", "ip", "192.168.1.1");

Output:

{
  "level": "INFO",
  "timestamp": "2026-02-17T12:00:00.000Z",
  "messageTemplate": "User {username} logged in from {ip}",
  "templateHash": "a1b2c3d4",
  "message": "User Alice logged in from 192.168.1.1",
  "properties": {
    "username": "Alice",
    "ip": "192.168.1.1"
  }
}

JSON Fields

Field Always Present Description
level Yes Log level string
timestamp Yes ISO 8601 with milliseconds
messageTemplate Yes Raw template with placeholders
templateHash Yes FNV-1a hash (8-char hex)
message Yes Rendered message
properties When placeholders exist Name-value map
file When source location captured Source file path
line When source location captured Line number
function When source location captured Function name
context When context is set Context key-value map

Properties with Operators

The @ operator causes type detection in JSON:

logger.info("Count: {@n}, Active: {@flag}", "n", 42, "flag", true);
{
  "properties": {
    "n": 42,
    "flag": true
  }
}

Without @, all values are strings:

{
  "properties": {
    "n": "42",
    "flag": "true"
  }
}

The $ operator forces string output:

logger.info("ID: {$id}", "id", 42);
// "properties": {"id": "42"}

XML Formatter

logger.addSink<minta::FileSink, minta::XmlFormatter>("app.xml");
logger.info("User {username} logged in", "username", "Alice");

Output:

<log level="INFO" timestamp="2026-02-17T12:00:00.000Z">
  <messageTemplate>User {username} logged in</messageTemplate>
  <templateHash>a1b2c3d4</templateHash>
  <message>User Alice logged in</message>
  <properties>
    <property name="username">Alice</property>
  </properties>
</log>

XML Operator Attributes

logger.info("{@count} items, ID: {$id}", "count", 42, "id", 100);
<properties>
  <property name="count" destructure="true">42</property>
  <property name="id" stringify="true">100</property>
</properties>

Template Hashing

Every log entry gets a templateHash — an FNV-1a hash of the message template string, rendered as 8-character hex.

This enables log aggregation tools (Seq, Elasticsearch, Splunk) to group entries by template without expensive string comparison:

logger.info("User {name} logged in", "name", "Alice");
logger.info("User {name} logged in", "name", "Bob");
// Both have the same templateHash — same template, different values

Use Cases

  • Group related logs in dashboards
  • Count occurrences of specific log patterns
  • Detect new log patterns (unknown hash = new code path)

Collision Note

FNV-1a with 32 bits can produce collisions. The hash is for grouping efficiency, not cryptographic uniqueness. Rare collisions in log aggregation are tolerable.

Template Cache

Parsed templates are cached to avoid re-parsing on repeated log calls:

// Default: 128 entries
logger.setTemplateCacheSize(256);  // increase for apps with many templates
logger.setTemplateCacheSize(0);    // disable caching entirely

How It Works

  1. First time a template is seen → parse and cache
  2. Subsequent calls with the same template → use cached parse result
  3. When cache is full → existing entries stay cached, new templates are parsed every time (cap-without-eviction)

When to Adjust

  • High-cardinality templates (many unique messages): Increase cache size or disable
  • Few recurring templates (typical apps): Default 128 is fine
  • Memory-constrained environments: Reduce or disable

Performance Impact

Template parsing involves string scanning and placeholder extraction. For hot-path logging with the same template, caching eliminates this cost entirely. The cache uses std::unordered_map keyed on the template string.

Source Location

Enable source location capture for debugging:

logger.setCaptureSourceLocation(true);
logger.info("Something happened");

JSON output:

{
  "level": "INFO",
  "message": "Something happened",
  "file": "/path/to/main.cpp",
  "line": 42,
  "function": "main"
}

Human-readable output:

[2026-02-17 12:00:00.000] [INFO] [main.cpp:42] Something happened

Context in Structured Output

logger.setContext("service", "auth");
logger.setContext("version", "1.2.3");
logger.info("Request received");

JSON output:

{
  "message": "Request received",
  "context": {
    "service": "auth",
    "version": "1.2.3"
  }
}

Combining Everything

minta::LunarLog logger(minta::LogLevel::INFO, false);
logger.addSink<minta::FileSink, minta::JsonFormatter>("structured.json");
logger.setCaptureSourceLocation(true);
logger.setContext("env", "production");
logger.setLocale("en_US");

logger.info("Order {@orderId} total: {amount:n}", "orderId", 12345, "amount", 9999.99);
{
  "level": "INFO",
  "timestamp": "2026-02-17T12:00:00.000Z",
  "messageTemplate": "Order {@orderId} total: {amount:n}",
  "templateHash": "f3a1b2c4",
  "message": "Order 12345 total: 9,999.99",
  "file": "main.cpp",
  "line": 10,
  "function": "main",
  "properties": {
    "orderId": 12345,
    "amount": "9999.99"
  },
  "context": {
    "env": "production"
  }
}

Note: @orderId → native number in JSON. amount without @ → string.

Compact JSON

For production pipelines where payload size matters, use CompactJsonFormatter instead. It produces single-line JSONL with short keys and flattened properties.

See Compact JSON Formatter for the full guide.

Clone this wiki locally