FirmwareBy Alison Alva••5 min read
LogAnywhere: Logging router for resource-constrained devices
I made a device-agnostic logging router for use on embedded devices with resource constraints. It's pretty useful.
EmbeddedDebuggingC++IoTLoggingMQTT
<p></p><h2 id=""></h2><p>I've done a lot of embedded programming over the last few years and as a result I've found myself re-implementing the same style of log output to various outputs over the last few years. </p><p>I’m pretty willing to write my own libraries when I'm working in embedded land as I often find myself off the beaten path doing things that others haven't written standardized libraries for, however I was kind of surprised that there weren't any light weight log routers for this kind of thing.</p><p><br><strong>LogAnywhere</strong> is a header-only C++ logging router that solves the multi-destination problem by decoupling routing from output. It's basically a switchboard: you give it log messages tagged with pointers to Tag objects, and it routes them to handlers subscribed to those tags based on severity levels you configure.</p><p>No heap allocations. Just static arrays with a deterministic memory footprint you configure at compile time. </p><p><strong>GitHub:</strong> <a href="https://github.com/Allie-Leth/LogAnywhere?ref=ghost.scopecreep.productions">github.com/Allie-Leth/LogAnywhere</a></p><h2 id="why">Why?</h2><h3 id="header-only-c11-friendly">Header-Only, C++11-Friendly</h3><p>I wanted this to work everywhere-ESP32, AVR, STM32, even desktop builds for testing. Header-only means no build system gymnastics, no library linking headaches. You <code>#include</code> it and you're done.</p><p>The library is C++11/C++14 compatible with no STL dependencies in the hot path. No <code><vector></code>, no <code><string></code>, no <code><optional></code>. Just plain C++ and C headers (<code><cstdio></code> for vsnprintf formatting).</p><h3 id="static-only-allocation">Static-Only Allocation</h3><p>This was non-negotiable. Embedded systems with limited RAM can't afford unpredictable heap usage. LogAnywhere uses only static storage-fixed-size arrays configured at compile time via macros:</p><pre><code class="language-cpp">#define LOGANYWHERE_MAX_HANDLERS 8
#define MAX_TAG_SUBSCRIPTIONS 6
#include "LogAnywhere.h"
</code></pre><p>Default is 6 handlers and 6 tag subscriptions (minimal footprint). On an ESP32 with plenty of RAM, I bump it to 16 handlers and 12 subscriptions. On a tiny AVR with 2KB SRAM, I drop it to 4 and 4. Same code, different capacity tradeoffs.</p><h3 id="tag-based-dispatch">Tag-Based Dispatch</h3><p>Version 1.1.0 removed all legacy string/tagFilter APIs in favor of exact <code>Tag*</code> subscriptions. Tags are static objects you define once:</p><pre><code class="language-cpp">static Tag TAG_SENSOR("sensor");
static Tag TAG_NETWORK("network");
static Tag TAG_POWER("power");
</code></pre><p>Each Tag maintains its own subscriber list. When you log to a tag, only handlers subscribed to that specific Tag pointer get called. No string comparisons, no hash lookups-just direct pointer dispatch.</p><hr><h2 id="how-it-works">How It Works</h2><h3 id="handler-signature">Handler Signature</h3><p>A handler is a function pointer that takes a <code>LogMessage</code> and optional context:</p><pre><code class="language-cpp">void serialHandler(const LogMessage& msg, void* ctx) {
Serial.printf("[%s] %s: %s\n",
toString(msg.level), // DEBUG, INFO, WARN, ERROR
msg.tag, // "sensor"
msg.message); // "Temperature: 23.5°C"
}
</code></pre><p>The <code>LogMessage</code> struct contains:</p><ul><li><code>LogLevel level</code> - severity (DEBUG/INFO/WARN/ERROR)</li><li><code>const char* tag</code> - tag name string</li><li><code>const char* message</code> - formatted message</li><li><code>uint64_t timestamp</code> - optional timestamp</li></ul><h3 id="registering-handlers">Registering Handlers</h3><p>You create a <code>HandlerManager</code>, register handlers with Tag subscriptions, then log through a <code>Logger</code> instance:</p><pre><code class="language-cpp">HandlerManager mgr;
Logger log(&mgr);
// Subscribe serialHandler to TAG_SENSOR and TAG_NETWORK
const Tag* tags[] = { &TAG_SENSOR, &TAG_NETWORK };
mgr.registerHandlerForTags(
LogLevel::DEBUG, // Minimum level
serialHandler, // Callback function
nullptr, // Context pointer (optional)
tags, // Tag array
2 // Tag count
);
// Now log to those tags
log.log(LogLevel::INFO, &TAG_SENSOR, "Temperature: 23.5°C");
log.logf(LogLevel::WARN, &TAG_NETWORK, "Mesh hop count: %d", hopCount);
</code></pre><h3 id="the-routing-logic">The Routing Logic</h3><p>When you call <code>log()</code> or <code>logf()</code>:</p><ol><li><strong>Format (if needed):</strong> <code>logf()</code> formats arguments into a 256-byte stack buffer using <code>vsnprintf</code></li><li><strong>Dispatch:</strong> Walk the Tag's subscriber list</li><li><strong>Filter:</strong> For each handler, check if severity >= handler's minimum level</li><li><strong>Invoke:</strong> Call matching handlers with the LogMessage</li></ol><p>No allocations. No string copies beyond the initial format buffer. Just function pointer dispatch.</p><hr><h2 id="real-world-usage">Real-World Usage</h2><h3 id="mesh-network-nodes-esp32">Mesh Network Nodes (ESP32)</h3><p>Each node runs with this config:</p><pre><code class="language-cpp">#define LOGANYWHERE_MAX_HANDLERS 16
#define MAX_TAG_SUBSCRIPTIONS 12
</code></pre><p>Handler setup:</p><pre><code class="language-cpp">// Define tags
static Tag TAG_SENSOR("sensor");
static Tag TAG_NETWORK("network");
static Tag TAG_POWER("power");
static Tag TAG_ERROR("error");
// Serial console (all tags, debug builds only)
#ifdef DEBUG_BUILD
const Tag* allTags[] = { &TAG_SENSOR, &TAG_NETWORK, &TAG_POWER, &TAG_ERROR };
mgr.registerHandlerForTags(LogLevel::DEBUG, serialHandler, nullptr, allTags, 4);
#endif
// SD card (network, power, error)
const Tag* sdTags[] = { &TAG_NETWORK, &TAG_POWER, &TAG_ERROR };
mgr.registerHandlerForTags(LogLevel::INFO, sdCardHandler, &sdFile, sdTags, 3);
// MQTT telemetry (sensor only)
const Tag* mqttTags[] = { &TAG_SENSOR };
mgr.registerHandlerForTags(LogLevel::INFO, mqttHandler, &mqttClient, mqttTags, 1);
// Watchdog (error only)
const Tag* errorTags[] = { &TAG_ERROR };
mgr.registerHandlerForTags(LogLevel::ERROR, watchdogHandler, nullptr, errorTags, 1);
</code></pre><p>RAM usage: on the order of ~800 bytes, depending on the config. Totally fine on a board with 320KB.</p><h3 id="sensor-nodes-avr-atmega328p">Sensor Nodes (AVR ATmega328P)</h3><p>Budget nodes with 2KB SRAM run a stripped config:</p><pre><code class="language-cpp">#define LOGANYWHERE_MAX_HANDLERS 4
#define MAX_TAG_SUBSCRIPTIONS 4
</code></pre><p>Handlers:</p><ul><li>Serial (debug builds only)</li><li>I2C to coordinator</li><li>EEPROM ring buffer (errors)</li><li>Watchdog (errors)</li></ul><p>RAM usage: ~200 bytes. Usable on the even space constrained devices.</p><hr><h2 id="what-i-learned-building-this">What I Learned Building This</h2><h3 id="pointer-based-tags-beat-string-matching">Pointer-Based Tags Beat String Matching</h3><p>Early versions did string comparisons to route messages. On AVR, string compares were eating cycles. Switching to Tag pointers meant zero-cost dispatch-just array lookups and pointer comparisons.</p><p>Each Tag is ~112 bytes (depends on <code>MAX_TAG_SUBSCRIPTIONS</code>), but you define them once as static globals. The memory is paid upfront, not per-log-call.</p><h3 id="compile-time-configuration-forces-good-decisions">Compile-Time Configuration Forces Good Decisions</h3><p>I initially tried runtime limits you could set. Bad idea. You'd allocate for worst-case, waste RAM, or hit limits unexpectedly. Compile-time macros force you to think about resource tradeoffs upfront.</p><p>Bonus: the compiler can optimize knowing exact array sizes.</p><h3 id="test-on-the-smallest-target-first">Test on the Smallest Target First</h3><p>I developed mostly on ESP32's because it's easy to debug. Then I'd port to AVR and hit RAM limits. Flipped my workflow: if it runs on an ATmega328P with 2KB RAM, it'll run anywhere. ESP32 builds became "turn up the capacity knobs."</p><h3 id="not-truly-zero-overhead">Not Truly "Zero Overhead"</h3><p>The marketing says "zero allocation," which is true. But "zero overhead" is an exaggeration:</p><ul><li><code>logf()</code> formats into a 256-byte stack buffer with <code>vsnprintf</code></li><li>Dispatch walks the Tag's subscriber array (O(subscribers))</li><li>Each handler call has the usual function pointer overhead</li></ul><p>It's fast enough for embedded use cases, but it's not literally free. Be honest about what "zero allocation" actually means.</p><hr><h2 id="design-decisions-id-revisit">Design Decisions I'd Revisit</h2><h3 id="the-256-byte-format-buffer">The 256-Byte Format Buffer</h3><p><code>logf()</code> uses a fixed 256-byte stack buffer for formatting. This is fine for most embedded use cases, but occasionally I hit the limit with long debug messages and have to break them up.</p><p>I could make it configurable (<code>#define LOGANYWHERE_FORMAT_BUFFER 512</code>), but that increases stack pressure on tiny MCUs. Trade-off I haven't solved yet.</p><h3 id="tag-memory-bloat">Tag Memory Bloat</h3><p>Each Tag is ~112 bytes (with default <code>MAX_TAG_SUBSCRIPTIONS=6</code>). If you have 20 tags, that's 2.2KB of static RAM. On AVR, that's significant.</p><p>I considered a global subscription table instead of per-Tag lists, but that makes the API messier and removes the zero-search-cost property of Tag pointers. </p><h3 id="no-async-buffering">No Async Buffering</h3><p>LogAnywhere is synchronous-when you log, all matching handlers get called immediately. If a handler blocks (like writing to slow SD card), your logging call blocks.</p><p>I considered adding a ring buffer for async dispatch, but that requires threading primitives and complicates the zero-allocation guarantee. For most embedded use cases, handlers are fast enough (serial, I2C, memory writes). MQTT is the slow one, but I batch those separately.</p><h2 id="current-status">Current Status</h2><p><strong>Version:</strong> 1.1.0 (MIT licensed)<br><strong>Testing:</strong> Catch2 test suite<br><strong>Current Use:</strong> Running on my mesh network nodes for 8+ months</p><p>The core router is stable and I’m only adding features as real projects demand.<br>Planned (when time allows): an optional NVM-backed handler for crash-persistent logs and revisiting my async designs.<br>PRs are welcome as long as they preserve the static-allocation / deterministic-footprint approach.</p><h2 id="-1"></h2><h2 id="technical-details">Technical Details</h2><p><strong>Repository:</strong> <a href="https://github.com/Allie-Leth/LogAnywhere?ref=ghost.scopecreep.productions">github.com/Allie-Leth/LogAnywhere</a><br><strong>Language:</strong> C++11/14 compatible<br><strong>Build:</strong> CMake<br><strong>Tests:</strong> Catch2<br><strong>License:</strong> MIT</p><p>Core files:</p><ul><li><code>LogAnywhere.h</code> - Main single-include header</li><li><code>Tag.h</code> - Tag struct and subscription management</li><li><code>HandlerManager.h</code> - Handler registration</li><li><code>Logger.h</code> - Logging interface and dispatch</li></ul><p>Example usage and integration guides are in the README. Issues and PRs welcome, especially for platform-specific quirks I haven't encountered.</p><p></p><h2 id="honest-assessment">Honest Assessment</h2><p>LogAnywhere solved my specific problem: routing logs to multiple outputs on resource-constrained devices without heap allocations. It's not the most feature-rich logging library, and the static configuration can be annoying when your needs change. But for embedded IoT work where you know your constraints upfront, it's been solid.</p><p>The Tag pointer dispatch is fast, the memory footprint is predictable, and I haven't had a single crash or memory leak in 8 months of my use. That's good enough for me.</p>