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.

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 (errorverbose) 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:

  1. captures args and forwards to vlog(Level::Info, …);
  2. is dropped early if the logger is uninitialized, has no sink, or the level is below the threshold;
  3. formats the body into a fixed 256-byte stack buffer with vsnprintf (bounded — never overflows);
  4. reads the timestamp from the injected clock;
  5. writes [millis][TAG] message\n to the sink in one printf.

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:

  1. write staged new lines first;
  2. copy the existing log after them in 512-byte chunks;
  3. stop when the configured cap is reached;
  4. 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

FileRole
include/OXLog.hPublic API, Level enum, the static state and injectable sink/clock declarations.
src/OXLog.cppThe whole implementation: begin() overloads, level methods, vlog() formatting/filtering, levelTag(). Portable — no device-only TU.
test/test_oxlog/StringPrint.hA capturing Print sink for host tests.
test/test_oxlog/test_oxlog.cppUnity suite: format, per-level tags, printf expansion, level filtering, lifecycle/guards.