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
@type level() :: Logger.level()
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.
@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
@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\""]}
Logs report at the :error level. See report/3.
Examples
iex> Clarion.error(%{event: :request_failed, reason: :timeout})
:ok
Logs report at the :info level. See report/3.
Examples
iex> Clarion.info(%{event: :cache_hit, key: "user:42"})
:ok
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- areport_cb/0used to renderreportfor handlers that want text. Defaults todefault_report_cb/1. Whatever you pass here, Clarion stores it in metadata and never invokes it — it cannot causereportto be lost, because Clarion never rewrites the event's message with the rendered result.- any other option is forwarded as
:loggermetadata, exactly as withLogger.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"
Logs report at the :warning level. See report/3.
Examples
iex> Clarion.warning(%{event: :retry, attempt: 1})
:ok