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 :logger formatter). See comparison.md. Clarion sits upstream of that boundary, at the call site.
  • A Credo custom check for "don't interpolate dynamic data into a Logger message 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 runtime ArgumentError for 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_cb form (fun(report, report_cb_config) -> chardata), which lets a renderer honor a specific handler's chars_limit/depth/single_line settings. default_report_cb/1 implements 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.