This is a record of the deliberate, debatable choices in Clarion's design — what was chosen, why, and what was considered and rejected or deferred.
1. Clarion.info/2, Clarion.warning/2, Clarion.error/2 reject bare strings
The decision: unlike Logger.info/2, which accepts a string, chardata,
or a report, Clarion's equivalents only accept a map or keyword list. Call
Clarion.info("something happened") and you get a compile-time-visible
ArgumentError at runtime, not a log line.
Why: section 4 of how_logging_actually_works.md shows, with a real
example, that the moment a report is interpolated into a string (or a
string is logged directly), the structure is gone before :logger ever
sees the call — no formatter, handler, or clever configuration downstream
can recover it. That failure happens at exactly one place: the call site.
It's also the easiest place to prevent, because it's the one place Clarion
actually controls.
An API that accepts a string "for convenience" is an API that will eventually receive one under deadline pressure, and the whole point of the library evaporates one call at a time. Making the correct call the only possible call is a stronger guarantee than documentation ("please always pass a map") could ever be.
The cost, honestly: this makes Clarion a worse fit for the case where a
library genuinely has nothing structured to say — "connection established"
with no other data. In that case the answer is Clarion.info(%{event: :connection_established}), a one-key map, which is mildly more ceremony
than Logger.info("connection established"). That's an intentional
trade: a little ceremony on the trivial case, in exchange for the
interpolation case being unreachable.
2. Clarion never calls report_cb itself
The decision: report/3 stores whatever report_cb it's given (custom
or default_report_cb/1) into :logger metadata, and never invokes it.
The rendered text is produced later, by whichever consumer (typically a
console handler's formatter) wants text.
Why: this is the actual correctness property the library exists to
guarantee, and it's structural, not incidental. If Clarion called
report_cb itself and passed the rendered string onward — even alongside
the original map — there would be a code path where a caller-supplied
report_cb runs before the map reaches :logger, and any bug or
side effect in that callback runs on Clarion's critical path. Never calling
it means a broken report_cb can make console output ugly or wrong; it can
never make the underlying report disappear, and it can never crash the
call to Clarion.report/3 itself before the log event is even emitted.
Section 5 of the research doc shows the one place inside :logger this
can still go wrong (a primary filter that does the eager rendering) —
that's out of Clarion's reach by construction, but Clarion itself will
never introduce it.
3. report/3, info/2, warning/2, error/2 are macros, not functions
The decision: all four are defmacro, mirroring Logger.info/2 and
friends, rather than plain functions.
Why: Elixir's Logger macros capture the caller's :file, :line,
and :mfa at compile time (verified by reading Logger's own
macro_log/4) and pass them as log metadata. A plain function wrapping
Logger.log/3 would report Clarion's own file/line as the log location
(or nothing), which is actively misleading for anyone trying to find where
a log line came from — for a library whose entire premise is
"logging correctness," shipping that regression would be a contradiction.
Verified empirically (see the scratch script referenced in the commit
history / PR description) that a macro which simply expands into a
Logger.log/3 call correctly resolves to the original caller's
file/line/mfa, not the wrapper's — because Elixir expands nested macros
depth-first within the compiling module, so Logger.log/3's own
__CALLER__ ends up bound to the real call site regardless of how many
macro layers are in between.
The cost: callers must require Clarion (not just alias or import
loosely) to use these, matching exactly what require Logger already
requires today — not a new burden, the same one. Macros also can't be
passed around as function values (&Clarion.info/2 doesn't work the way
&Logger.info/2 doesn't either) — an acceptable and precedented cost.
Dialyzer note: because these are macros, @spec is not attached to
them directly — Dialyzer analyzes the expanded call site, not the macro
definition, and Elixir's own Logger macros follow the same convention
(no @spec on Logger.info/2). @spec is provided on every plain
function in this library (default_report_cb/1, and the internal
validation helper).
4. default_report_cb/1 renders via {"~ts", [text]}, never {text, []}
The decision: the default renderer always returns a fixed ~ts format
string with the actual rendered text as its one argument — never returns
the rendered text itself as the format string.
Why: verified directly — :io_lib.format/2 interprets its first
argument as a format string containing directives like ~p, ~s, ~n.
A report value containing a literal ~ (e.g. %{note: "50~60% CPU"})
would, if the rendered text were used as the format string, be
misinterpreted as an incomplete format directive and crash the renderer —
turning a log call into a runtime exception. Treating the rendered text as
data, not as a format string, avoids this entirely regardless of what a
report's values contain.
5. Keys are sorted in default_report_cb/1's output
The decision: the default rendering sorts a report's keys
alphabetically (with :event, if present, pulled out and rendered first,
bare).
Why: determinism. A map has no defined iteration order in general(*), so
without sorting, the same report could render differently between runs —
undesirable for anything eyeballing console output for consistency, and
actively bad for doctests/tests that assert on exact rendered strings. This
is purely Clarion's own default; a caller who wants their report_cb to
preserve keyword-list order is free to write one — Clarion never inspects
or overrides a caller-supplied report_cb.
(*) In current implementations, small maps built with literal keys do iterate in insertion order, but that's an implementation detail no code should depend on — sorting is honest here rather than relying on it.
Considered and deferred
These were discussed and explicitly scoped out of v1, not overlooked:
- JSON output formatting. Not Clarion's job —
logger_json,logger_formatter_json, and friends already solve this well, at the correct layer (a:loggerformatter). Seecomparison.md. Clarion sits upstream of that boundary, at the call site. - A Credo custom check for "don't interpolate dynamic data into a
Loggermessage string." A natural v2 companion to this library — it would catch the section-4 failure mode statically, before the code ever runs, which is strictly better than Clarion's runtimeArgumentErrorfor the subset of cases a static check can see. Flagged, not built, for v1. - Backend/handler shipping (Loki, Datadog, Betterstack integrations).
A different layer entirely — see
comparison.md. Out of scope, permanently, not just for v1. - The 2-arity
report_cbform (fun(report, report_cb_config) -> chardata), which lets a renderer honor a specific handler'schars_limit/depth/single_linesettings.default_report_cb/1implements only the 1-arity form for v1. Worth adding later for callers who want their custom renderer to respect per-handler formatting preferences, but not required for the core correctness property this library exists to demonstrate.