How OXLog Works
A walkthrough of the design and internals
What this page is
The API reference tells you what each method does. This page explains how the class is built and why — the all-static design, the injectable sink and clock that make it testable, how a log line is formatted, and how the device and host builds differ. Read it before changing the output format, the level filtering, or the transport.
The design: one global logger, statically dispatched
Logging is a cross-cutting concern: every file wants to emit a message
without being handed a logger object first. So OXLog is
all-static — there is no instance to construct or pass
around. State lives in a handful of file-scope statics in
src/OXLog.cpp:
OXLog::Level OXLog::level_ = OXLog::Level::Info; // minimum severity
bool OXLog::initialized_ = false; // begin() called yet?
Print *OXLog::sink_ = nullptr; // where output goes
unsigned long (*OXLog::clock_)() = nullptr; // timestamp source
The trade-off of a global is that it is shared mutable state — but for a
logger that is exactly what you want: OXLog::info(...) works
from anywhere, and there is a single place to change verbosity or
redirect output.
Testability: injectable sink and clock
A logger that hard-codes Serial and millis()
cannot be unit-tested on a host — there is no serial port, and the
timestamp changes every run. OXLog avoids both by treating the
output sink and the clock as injectable
dependencies, the same pattern the other OX classes use for their hardware.
-
The sink is an Arduino
Print *— the base class thatSerialalready implements. The device path points it atSerial; a test points it at a string-capturingPrintand asserts on the exact bytes written. -
The clock is a function pointer
unsigned long (*)(). The device path usesmillis; a test installs a fixed clock so the timestamp in the output is deterministic.
That is why there are two begin() overloads — one wires up the
real serial transport, the other accepts any Print:
void OXLog::begin(unsigned long baud, Level level) {
level_ = level;
Serial.begin(baud);
sink_ = &Serial; // device: log to USB CDC serial
clock_ = &millis; // device: real uptime
initialized_ = true;
}
void OXLog::begin(Print &sink, Level level) {
level_ = level;
sink_ = &sink; // test/alt: log to any Print
clock_ = &millis;
initialized_ = true;
}
reset() exists for the same reason: it returns the statics to
their pre-begin() state so each unit test starts clean.
One log call, end to end
The five public methods (error … verbose) are
thin wrappers that capture their variadic arguments and forward to a single
private vlog():
void OXLog::info(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
vlog(Level::Info, fmt, args); // all levels funnel through one place
va_end(args);
}
vlog() is where filtering and formatting happen:
void OXLog::vlog(Level level, const char *fmt, va_list args) {
// Drop if not started, no sink, the None level, or below the threshold.
if (!initialized_ || sink_ == nullptr || level == Level::None ||
level > level_) {
return;
}
char buffer[256];
vsnprintf(buffer, sizeof(buffer), fmt, args); // format the message body
unsigned long now = clock_ != nullptr ? clock_() : 0;
sink_->printf("[%lu][%s] %s\n", now, levelTag(level), buffer);
}
So a single OXLog::info("x=%d", 7) call:
- captures
argsand forwards tovlog(Level::Info, …); - is dropped early if the logger is uninitialized, has no sink, or the level is below the threshold;
- formats the body into a fixed 256-byte stack buffer with
vsnprintf(bounded — never overflows); - reads the timestamp from the injected clock;
- writes
[millis][TAG] message\nto the sink in oneprintf.
Level filtering: ordering is the whole trick
The single comparison level > level_ is what gates output,
and it works because Level is an ordered
enum class : uint8_t from least to most verbose:
enum class Level : uint8_t {
None = 0, Error, Warn, Info, Debug, Verbose
};
A message is emitted only when its level is numerically
less than or equal to the configured one. So setting the threshold
to Debug also lets Error, Warn, and
Info through, while Verbose is dropped — exactly
the intuitive "show this level and everything more important." Filtering
happens before vsnprintf, so a suppressed log costs
almost nothing: no formatting, no I/O.
Level::None is special-cased so it can be used both as a
threshold (set the level to None to silence everything) and is
never itself a valid message level.
The line format
Every line is [<millis>][<tag>] <message>
with a trailing newline added automatically — so call sites never include
one. The tag is a single letter from levelTag() (E,
W, I, D, V), which keeps
lines short and easy to grep or filter in a serial monitor. The
printf-style methods carry a
__attribute__((format(printf, 1, 2))) annotation, so the
compiler type-checks your format string against its arguments at build
time — a wrong %d/%s is a warning, not a runtime
surprise.
Note
The message body is formatted into a fixed 256-byte buffer. Longer
messages are safely truncated by vsnprintf (no overflow),
not wrapped — keep individual log lines under ~250 characters.
How file logging works
File logging is optional and is enabled only after external storage is
mounted with OXLog::beginFileLogging(files). Every emitted
line is still written to the normal Print sink, then copied
into a small newest-first RAM staging buffer. poll() or
flushFileLog() rewrites /log/yyyyMMdd.log through
a temp file:
- write staged new lines first;
- copy the existing log after them in 512-byte chunks;
- stop when the configured cap is reached;
- rename the temp file into place.
That gives FIFO behavior for storage pressure: newest entries stay at the
top and old entries fall off the bottom once the file reaches the cap. The
default cap is 200 KB, but the RAM staging buffer is only
1 KB, which keeps the ESP32-S3 build within DRAM limits.
Before the wall clock is set, the filename naturally falls back to
19700101.log.
Device vs. host build
Unlike OXFileManager, OXSecurity, and OXAudioManager, OXLog
has no device-only translation unit — the whole class is
portable. It depends only on the Arduino Print interface and a
millis-shaped function pointer, both of which the host test
mock supplies. So the same src/OXLog.cpp compiles for the
ESP32 and for the native test runner; only the injected sink differs
(Serial on-device, a capture buffer in tests). That is the
payoff of the dependency injection: there is nothing to stub out at the
build-system level.
Where it fits
OXLog is the diagnostics backbone for the rest of the
namespace: OXFileManager,
OXSecurity, and
OXAudioManager all report
errors and status through it. Because the sink is swappable, redirecting
all of that output — to a file, a second UART, or a test buffer — is a
one-line change at begin() and touches no call sites.
File map
| File | Role |
|---|---|
include/OXLog.h | Public API, Level enum, the static state and injectable sink/clock declarations. |
src/OXLog.cpp | The whole implementation: begin() overloads, level methods, vlog() formatting/filtering, levelTag(). Portable — no device-only TU. |
test/test_oxlog/StringPrint.h | A capturing Print sink for host tests. |
test/test_oxlog/test_oxlog.cpp | Unity suite: format, per-level tags, printf expansion, level filtering, lifecycle/guards. |