Skip to content

feat!: rework logging as macros, with rate limiting and a trace level - #76

Merged
sbogomolov merged 10 commits into
mainfrom
logging/macro-api
Aug 11, 2026
Merged

feat!: rework logging as macros, with rate limiting and a trace level#76
sbogomolov merged 10 commits into
mainfrom
logging/macro-api

Conversation

@sbogomolov

Copy link
Copy Markdown
Owner

Why

Rate limiting needs per-call-site state, and a function API cannot have it: a
call site's identity reaches a function only as a value, never as something that
can name a distinct object. A macro expands at the call site, so a static
there belongs to that statement alone.

log_level = "warning" stops parsing; it is warn now. trace and off are new.

Choices worth flagging

The rate window is a consteval constructor argument. A template parameter
enforces the same thing but instantiates Admit per distinct window, ~259 bytes
a site in Debug for no Release gain. A constexpr local works but leaves
guard-freedom depending on that local surviving later edits. consteval makes a
runtime window a compile error by the language's own rule, and the gate stays
constant-initialized, so no site carries a thread-safe init guard.

Only 12 sites are gated. An earlier pass gated 78, everything that could
repeat. That reads worse: 78 independent windows means the surviving lines opened
at unrelated moments and the sequence no longer reconstructs. Gated now are the
lines a peer emits one-for-one with packets. Repair paths still log once per
retry, a rate we chose and one worth seeing.

IpAddress::ToChars is not gated despite being hot: it runs inside the
logger's own formatters, so a gate would put rate-limit state in the middle of
formatting a record.

Trace is absent from release objects, not skipped. NDEBUG lowers a
compile-time floor the macros check first, so the statement and its literal are
gone. The arguments still compile, so a trace line cannot rot in the build that
omits it.

26 tests lost a log assertion. A gate is a static that outlives the test
tripping it, so those passed only in the order they ran. Each sat beside an
assertion of the behaviour that produced the line, which is what remains. The
other 74 were audited and kept: a logger test asserting its own output has
nothing else to look at, and a name in a record does not prove the message after
it survived.

Logger held an owned string, a string_view either aliasing it or pointing at a
literal, and a flag saying which, so the move operations were written by hand
and their failure mode was a string_view left aliasing a moved-from string.

One string halves the object, 48 bytes to 24, and defaults the moves. It costs
an allocation for the two static names past libstdc++'s small-buffer limit,
both process-lifetime singletons.

The tests that went with it covered only the machinery: which constructor is
noexcept, and the char array without a trailing NUL that was the one case
telling the array constructor from the literal one.
log_level = "warning" is no longer accepted, and records that read WARNING now
read WARN, so configs and any log tooling matching the old spelling need
updating.

"warn" is the conventional short form and puts the four levels on an even
footing: debug, info, warn, error.
Rate limiting needs per-call-site state, and a function API cannot get it: the
call site's identity reaches a function only as a value, never as something
that can name a distinct object. A macro expands at the call site, so a static
there belongs to that statement alone.

Converting everything now rather than mixing two spellings later. The macros
take the logger explicitly, so the per-object loggers carrying "RawSocket:eth0"
and "DialProxy:tv:src->dst" are unchanged.

The level gate lives at the call site, so a filtered record no longer evaluates
its arguments: Interface::Refresh's Debug line was building three address
strings on every refresh at the default Info level, and raw_socket built an
Error::FromErrno for three suppressed lines. Emit replaces the four per-level
methods, which would otherwise be public and ungated.
Emit was a template, so the entire record builder was instantiated once per
combination of argument types: localtime_r, strftime, both format calls and the
fwrite, copied 86 times across the tree. Type-erasing the arguments through
make_format_args leaves each call site with just the argument array to build,
and the builder compiles once.

Release text drops from 425,984 to 376,832 bytes, around 10% of the stripped
binary, measured on an arm64 LTO build.

std::format has no vformat_to_n, so bounding the message buffer needs an output
iterator of our own to keep truncation working.
A failure that recurs per packet or per peer floods the log and buries
everything else. NFL_LOG_WARN_RATE and NFL_LOG_ERROR_RATE emit at most once per
window per call site, count what they swallow, and disclose the count on the
next line that gets through.

The window is a consteval constructor argument, so a site cannot pass one that
varies between calls, and the gate is constant-initialized rather than carrying
a thread-safe init guard, and its atomic load, into every call.

MonotonicSecs is one-based, which leaves 0 to mean "nothing emitted yet" even
for a daemon that logs during its first second of uptime, and makes the window
arithmetic underflow-free without a guard.

The disclosure is interpolated into the record rather than appended to the
message, so a message long enough to be truncated cannot take it down with it,
and an ordinary log never tests for one.
One inbound packet produces one of these lines, so a peer sets the rate: the
oversized-frame drops in both capture backends, the reflect failures in each
reflector, and the session, connection and reservation caps. One window for all
of them, so what survives is a coherent sample rather than a scatter of sites
whose windows opened at different times.

Nothing else is gated. A socket that will not open or a group that will not join
reports at the rate we chose to retry it, which is a rate worth seeing.

Ten tests asserted a gated line reached stdout beside an assertion of the
behaviour that produced it. A gate outlives the test that trips it, so those
passed only in the order they happened to run; the log assertions go.
Trace is for the detail that only matters when reconstructing what the daemon
did, so a release build drops those statements rather than testing a level for
them: NDEBUG lowers a compile-time floor the macros check before anything else.
The arguments still compile, so a Trace line cannot rot in the configuration
that leaves it out.

Off completes the set the config file accepts. Both levels parse from the file
and from REFLECTOR_LOG_LEVEL, which share a parser.

No site logs at Trace yet.
Debug now means one line per lifecycle event: an interface resolved, a group
joined, a capture re-bound. Everything that fires per packet, per fd operation
or per address examined moved to trace, which a release build does not carry.

The new lines are mostly address selection: which v4 won, which v6 took the
source or routable slot and at what rank, and what the flags filtered out. That
reasoning was invisible before, and it decides what the daemon can send from.
Sixteen tests asserted a line reached stdout beside an assertion that already
pinned the behaviour producing it: a reflector left invalid, a registration count
unchanged, a payload forwarded byte for byte. Log text is not a contract and the
assertions break on ordering once a site is rate limited, so the behavioural
assertion is the one to keep.

The rest stay. A logger test asserting its own output has nothing else to look
at, and a name in the record does not prove the message after it survived.
@sbogomolov sbogomolov self-assigned this Aug 11, 2026
@sbogomolov
sbogomolov merged commit 63fe48f into main Aug 11, 2026
17 checks passed
@sbogomolov
sbogomolov deleted the logging/macro-api branch August 11, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant