Clarion (Clarion v0.1.0)

Copy Markdown View Source

Clarion helps library authors emit logs that stay structured all the way through Erlang's :logger pipeline — readable for a human at the console, and still a real map for any handler downstream that wants to query it (JSON, Datadog, a test assertion) rather than re-parse a sentence.

See docs/how_logging_actually_works.md in the repository for the verified explanation of why this is a real problem in the current :logger/Logger pipeline, and docs/comparison.md for how this differs from JSON formatters (logger_json), text formatters (flatlog), and log-shipping backends — all of which operate on an event after it already exists, not at the call site that creates it.

The problem, concretely

Imagine an HTTP client library that retries a failed request. The naive way to log that:

Logger.info("retrying request to \#{url}, attempt \#{attempt}/\#{max}")

reads fine in a dev console. But the moment that log event reaches a JSON handler (Datadog, Loki, anything), all a consumer gets is one opaque string — url, attempt, and max are gone, baked into text that has to be regexed back apart if anyone wants to alert on "attempt >= 3", or is 3 = max.

With Clarion:

Clarion.info(%{event: :http_retry, url: url, attempt: attempt, max: max})

Every field arrives at every handler as real data — a JSON handler writes real url/attempt/max fields, a console handler prints a readable one-liner (http_retry url=... attempt=2 max=3) via default_report_cb/1, and nothing has to guess or re-parse.

Why Clarion.info/2 requires a map or keyword list, not a string

Clarion.info/2, Clarion.warning/2, and Clarion.error/2 deliberately do not accept a bare string, unlike Logger.info/2. This is the central, debatable design decision in this library — see docs/design_decisions.md for the full reasoning. In short: if the API accepts a string, someone will pass one, and once a report has been interpolated into a string at the call site, :logger never sees it as structured data again — no formatter or handler downstream can recover it. Requiring a report is a guardrail, not new plumbing.

Summary

Types

A log level, same as Logger.level/0.

A structured log report: a map or a keyword list of arbitrary application data. This is the only shape Clarion accepts as the thing being logged — never a string.

A renderer for a report/0, following the 1-arity form of Erlang's :logger report_cb() contract: given the report, return an :io_lib.format/2-style {format, args} pair used to produce the human-readable rendering for consumers (typically a console handler) that call it.

Functions

The default report_cb/0 used by report/3 when the caller doesn't supply one. Renders report as "key1=val1 key2=val2", with an :event field (if present) rendered first, bare, as a leading event name rather than event=....

Logs report at the :error level. See report/3.

Logs report at the :info level. See report/3.

Logs report at level, as a real structured :logger report.

Logs report at the :warning level. See report/3.

Types

level()

@type level() :: Logger.level()

A log level, same as Logger.level/0.

report()

@type report() :: map() | keyword()

A structured log report: a map or a keyword list of arbitrary application data. This is the only shape Clarion accepts as the thing being logged — never a string.

report_cb()

@type report_cb() :: (report() -> {:io.format(), [term()]})

A renderer for a report/0, following the 1-arity form of Erlang's :logger report_cb() contract: given the report, return an :io_lib.format/2-style {format, args} pair used to produce the human-readable rendering for consumers (typically a console handler) that call it.

Clarion never calls this function itself — see the moduledoc and docs/how_logging_actually_works.md for why that matters. It is stored in the log event's metadata for a consumer to call, if and when that consumer wants text instead of structured data.

Functions

default_report_cb(report)

@spec default_report_cb(report()) :: {:io.format(), [term()]}

The default report_cb/0 used by report/3 when the caller doesn't supply one. Renders report as "key1=val1 key2=val2", with an :event field (if present) rendered first, bare, as a leading event name rather than event=....

Keys are sorted for deterministic output. Values containing whitespace are wrapped in inspect/1 so the rendering stays parseable by eye and by simple tools (logfmt-style).

Examples

iex> Clarion.default_report_cb(%{event: :http_retry, attempt: 2})
{~c"~ts", ["http_retry attempt=2"]}

iex> Clarion.default_report_cb(%{key: "user:42"})
{~c"~ts", ["key=user:42"]}

iex> Clarion.default_report_cb(%{message: "hello world"})
{~c"~ts", ["message=\"hello world\""]}

error(report, opts \\ [])

(macro)

Logs report at the :error level. See report/3.

Examples

iex> Clarion.error(%{event: :request_failed, reason: :timeout})
:ok

info(report, opts \\ [])

(macro)

Logs report at the :info level. See report/3.

Examples

iex> Clarion.info(%{event: :cache_hit, key: "user:42"})
:ok

report(level, report, opts \\ [])

(macro)

Logs report at level, as a real structured :logger report.

report must be a map or a keyword list — see the moduledoc for why a bare string is rejected. Available opts:

  • :report_cb - a report_cb/0 used to render report for handlers that want text. Defaults to default_report_cb/1. Whatever you pass here, Clarion stores it in metadata and never invokes it — it cannot cause report to be lost, because Clarion never rewrites the event's message with the rendered result.
  • any other option is forwarded as :logger metadata, exactly as with Logger.log/3.

Examples

iex> Clarion.report(:info, %{event: :cache_miss, key: "user:42"})
:ok

iex> Clarion.report(:warning, [event: :retry, attempt: 1], request_id: "abc")
:ok

iex> Clarion.report(:info, "not a report")
** (ArgumentError) Clarion.report/3 requires a map or a keyword list, got: "not a report"

warning(report, opts \\ [])

(macro)

Logs report at the :warning level. See report/3.

Examples

iex> Clarion.warning(%{event: :retry, attempt: 1})
:ok