Best Practices

Using Spotflow with Resource-Constrained Devices

How to configure Spotflow's device module to fit tight RAM, flash, bandwidth, and power budgets on embedded hardware.

Spotflow's device module is designed to be tunable. Every major subsystem (logging, metrics, coredumps, and the TLS stack) exposes Kconfig options that control its RAM allocation, flash usage, transmission frequency, and CPU footprint. This page walks through the options most relevant to devices with limited resources and explains the trade-offs behind each one.

The guidance here targets Zephyr and Nordic nRF Connect SDK. ESP-IDF shares the same underlying principles but uses sdkconfig.defaults instead of prj.conf.

The defaults are intentionally set to cover the widest range of use cases and boards, which means they are higher than most production deployments actually require. This page gives an overview of the options for reducing memory and bandwidth allocations. If you have specific requirements or constraints, reach out via hello@spotflow.io or Discord and we will help you find the best optimization approach for your setup.

Memory

Memory usage is controlled through Kconfig heap additions. Each subsystem declares its own heap pool contribution; enabling a subsystem adds that amount to the system heap. Nothing is allocated silently.

The table below shows the default heap contribution for each subsystem:

SubsystemKconfig optionDefault
Logging backendHEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_LOGGING6 KB
Custom metricsHEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS8 KB
System metricsHEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS_SYSTEM20 KB
Coredumps backendHEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_COREDUMPS18 KB
Mbed TLSMBEDTLS_HEAP_SIZE32 KB

Metrics and coredumps are disabled by default (CONFIG_SPOTFLOW_METRICS=n, CONFIG_SPOTFLOW_COREDUMPS=n). With only logging enabled, the baseline is roughly 38 KB (6 KB logging + 32 KB Mbed TLS), plus a 2.5 KB processing thread stack.

The Mbed TLS heap dominates the baseline. Of the ~38 KB total, 32 KB is Mbed TLS and ~8.5 KB is Spotflow itself (6 KB logging heap + 2.5 KB thread stack). Mbed TLS uses this heap for the full TLS session: certificate chain verification, and input and output record buffers for the encrypted connection.

If your firmware already uses a TLS library, the 32 KB may not be an incremental cost as it may already be accounted for in your memory budget. If you are evaluating footprint against other solutions, compare the numbers without the TLS layer unless the competing solution includes one too. If you are using a different TLS library or need a lower-footprint TLS option, contact us and we can look at adjusting the integration.

Reducing the logging heap

The logging heap is sized to hold a queue of CBOR-encoded messages waiting to be sent. Three options control it:

Kconfig optionDefaultEffect
SPOTFLOW_LOG_BACKEND_QUEUE_SIZE16 messagesDepth of the in-RAM log queue
SPOTFLOW_LOG_BUFFER_SIZE512 bytesRaw per-message buffer before CBOR encoding
SPOTFLOW_CBOR_LOG_MAX_LEN512 bytes with compact logs; 1024 bytes otherwiseCBOR-encoded output buffer per message
SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATEyWhether to include the original format template alongside the interpolated message; unavailable with compact logs

If your log lines are short (under ~100 characters), you can safely reduce SPOTFLOW_LOG_BUFFER_SIZE and SPOTFLOW_CBOR_LOG_MAX_LEN. Messages that exceed the buffer are dropped.

Disabling SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATE omits the original format template from each log message, sending only the final interpolated string. This reduces per-message CBOR payload size at the cost of losing the template in the cloud.

With compact logs, neither SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATE nor SPOTFLOW_LOG_BUFFER_SIZE apply: the device sends binary arguments and does not format the log body.

prj.conf — reduced logging heap
# Default: ~6 KB heap
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_LOGGING=6144

# Constrained: ~3 KB heap
CONFIG_SPOTFLOW_LOG_BACKEND_QUEUE_SIZE=8
CONFIG_SPOTFLOW_LOG_BUFFER_SIZE=256
CONFIG_SPOTFLOW_CBOR_LOG_MAX_LEN=512
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_LOGGING=3072

Reducing the custom metrics heap

When you register a labeled metric, two parameters directly determine how much heap it consumes:

  • MAX_LABELS_PER_METRIC (Kconfig, compile-time) — the maximum number of label key-value pairs any single metric report will ever carry. Each label slot costs ~48 bytes per timeseries regardless of whether that slot is actually used, because the array is sized at compile time.
  • max_timeseries (registration call, per-metric) — the number of unique label combinations the SDK must hold in memory simultaneously. Each unique combination gets its own independent aggregation state (sum, count, min, max) for the full duration of the aggregation window.

The heap cost per metric follows:

timeseries_size = ~36 + (48 × MAX_LABELS_PER_METRIC)  bytes
per-metric heap  = 80 + (timeseries_size × max_timeseries)

max_timeseries should equal the number of unique label value combinations you expect at runtime. For example, a metric with a location label (3 possible values: north, south, east) and a channel label (2 possible values: a, b) produces 6 combinations, set max_timeseries=6. If a new combination appears beyond that limit, the SDK silently drops reports for it until the aggregation window resets.

The following table shows how MAX_LABELS_PER_METRIC affects timeseries size and the total heap for that 6-combination example:

MAX_LABELS_PER_METRICBytes per timeseriesHeap for 6 timeseries
1~84 bytes~590 bytes
2~132 bytes~875 bytes
4 (default)~228 bytes~1.5 KB
8 (max)~420 bytes~2.6 KB

Set MAX_LABELS_PER_METRIC to the highest label count used by any single metric in your application, not to a generous ceiling. If your most label-heavy metric uses 2 labels, set it to 2. System metrics use at most 1 label.

prj.conf — reduced custom metrics heap
# Default: 4 labels per timeseries, up to 32 metrics
CONFIG_SPOTFLOW_METRICS_MAX_LABELS_PER_METRIC=4
CONFIG_SPOTFLOW_METRICS_MAX_REGISTERED=32

# Constrained: trim to actual label usage
CONFIG_SPOTFLOW_METRICS_MAX_LABELS_PER_METRIC=2
CONFIG_SPOTFLOW_METRICS_MAX_REGISTERED=8
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS=4096

Reducing the system metrics heap

The system metrics heap is the largest contributor after Mbed TLS. Thread stack monitoring dominates its cost: with the default of tracking up to 32 threads automatically, stack metrics alone account for roughly 15 KB of the 20 KB default.

prj.conf — reduced system metrics heap
# Default: 20 KB heap, all threads tracked automatically
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS_SYSTEM=20480
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_ALL_THREADS=y
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_MAX_THREADS=32

# Constrained: ~8 KB heap, only track specific threads manually
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_ALL_THREADS=n
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_MAX_THREADS=4
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS_SYSTEM=8192

When STACK_ALL_THREADS=n, register the threads you care about explicitly from application code:

src/main.c
spotflow_metrics_system_enable_thread_stack(my_critical_thread);

Reducing the coredumps heap

The coredumps heap is sized to buffer binary chunks before MQTT transmission. Reducing the chunk size and queue depth lowers the heap proportionally:

heap ≈ (CHUNK_SIZE × QUEUE_SIZE) + 2 KB
prj.conf — reduced coredumps heap
# Default: ~18 KB heap
CONFIG_SPOTFLOW_COREDUMPS_CHUNK_SIZE=1024
CONFIG_SPOTFLOW_COREDUMPS_BACKEND_QUEUE_SIZE=16
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_COREDUMPS=18432

# Constrained: ~6 KB heap
CONFIG_SPOTFLOW_COREDUMPS_CHUNK_SIZE=512
CONFIG_SPOTFLOW_COREDUMPS_BACKEND_QUEUE_SIZE=8
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_COREDUMPS=6144

On boards with limited flash for storing the coredump image before upload, also enable:

prj.conf
CONFIG_DEBUG_COREDUMP_MEMORY_DUMP_MIN=y

Flash Memory

Format string stripping

To save flash memory, remove log format strings from the image flashed to the device. With compact logs, Spotflow can read those strings from the firmware ELF file instead.

LOG_FMT_SECTION places constant log format strings and module names in a dedicated section. LOG_FMT_SECTION_STRIP keeps that section in the ELF file for decoding while excluding it from the device image. Enable both options alongside compact logs:

prj.conf — compact logs with format string stripping
CONFIG_SPOTFLOW_COMPACT_LOGS=y
CONFIG_LOG_BACKEND_UART=n
CONFIG_LOG_OUTPUT=n
CONFIG_LOG_FMT_SECTION=y
CONFIG_LOG_FMT_SECTION_STRIP=y

Upload the matching ELF file through Firmware Management so Spotflow can resolve the log strings.

UART logging with stripped format strings

Zephyr's UART backend defaults to text output, which enables CONFIG_LOG_OUTPUT; format string stripping requires CONFIG_LOG_OUTPUT=n. Set CONFIG_LOG_BACKEND_UART=n to disable UART logging, or keep UART enabled and set CONFIG_LOG_BACKEND_UART_OUTPUT_DICTIONARY_BIN=y (or CONFIG_LOG_BACKEND_UART_OUTPUT_DICTIONARY_HEX=y). Dictionary output requires Zephyr's offline log parser to read the UART logs. The dictionary UART format is separate from the compact-log messages the Spotflow module sends.

Source labels on Zephyr 3.7

Each message sent from a Zephyr log source (i.e., not redirected from printk) normally contains the log source name under the label source. The only exception is when CONFIG_LOG_FMT_SECTION_STRIP=y is used on Zephyr 3.7 or older, because the source name string address is not present there.

The following table illustrates how format string stripping reduces image size on the OTA update sample. The sample was built for FRDM-RW612 with Zephyr 4.4.0, Zephyr SDK 1.0.1, and size optimization. Wi-Fi, OTA updates, metrics, and MCUboot were enabled, and application UART logging was disabled. The table compares basic compact logs without a format section against the stripping configuration above. Sizes are application zephyr.bin bytes:

Compile-time default log levelBasic compact logsWith strippingFlash savedReduction
WARNING (2)752,792 bytes738,268 bytes14,524 bytes1.93%
INFO (3)757,716 bytes741,500 bytes16,216 bytes2.14%
DEBUG (4)771,132 bytes749,444 bytes21,688 bytes2.81%

As you can see, the DEBUG log level with format string stripping consumes less flash memory than the WARNING level without it.

Bandwidth

Log level filtering

The most direct bandwidth reduction is filtering out low-severity logs before they are serialized and queued. Use SPOTFLOW_DEFAULT_SENT_LOG_LEVEL to set the initial filter at boot time:

ValueLevelWhen to use
1ERRORProduction devices: only failures
2WARNINGProduction devices: failures and anomalies
3INFOStaging / burn-in: standard operational logs
4DEBUGDevelopment: all messages (default)
prj.conf — production log filter
# Default: send all log levels (DEBUG and above)
CONFIG_SPOTFLOW_DEFAULT_SENT_LOG_LEVEL=4

# Production: send only WARNING and ERROR
CONFIG_SPOTFLOW_DEFAULT_SENT_LOG_LEVEL=2

You can also change the log level at runtime from the Spotflow portal without a firmware update (see Adjust Device's Minimal Log Severity). On MQTT connect, the device reports its current level; when you change it in the portal, the device applies it immediately. To persist the level across reboots so it takes effect before the first MQTT connection, enable the Zephyr settings subsystem:

prj.conf
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_NVS=y
CONFIG_SETTINGS=y
CONFIG_SETTINGS_NVS=y
# CONFIG_SPOTFLOW_SETTINGS=y is set automatically when CONFIG_SETTINGS=y

Also suppress the device module's own internal logs in production builds:

prj.conf
# 1 = ERROR only (defaults to LOG_DEFAULT_LEVEL)
CONFIG_SPOTFLOW_MODULE_DEFAULT_LOG_LEVEL=1

Metrics aggregation

Metrics are aggregated on-device before transmission: individual samples are accumulated and sent as a single message containing sum, count, min, and max for the window. This means transmission frequency is decoupled from sampling frequency.

Aggregation intervalConstantTransmissions per metric per day
No aggregationSPOTFLOW_AGG_INTERVAL_NONEOne per sample
1 minuteSPOTFLOW_AGG_INTERVAL_1MIN1440
1 hourSPOTFLOW_AGG_INTERVAL_1HOUR24
1 daySPOTFLOW_AGG_INTERVAL_1DAY1

One-hour aggregation is typically sufficient for fleet health monitoring. One-day aggregation is practical for low-bandwidth deployments where only long-term trends matter. No aggregation is appropriate for event-like metrics where each occurrence is individually significant.

The default aggregation interval and heartbeat frequency are both configurable:

prj.conf — reduced metric transmission
# Default: 1-minute aggregation, 60-second heartbeat
CONFIG_SPOTFLOW_METRICS_DEFAULT_AGGREGATION_INTERVAL=60
CONFIG_SPOTFLOW_METRICS_HEARTBEAT_INTERVAL=60

# Low-bandwidth: 1-hour aggregation, 1-hour heartbeat
CONFIG_SPOTFLOW_METRICS_DEFAULT_AGGREGATION_INTERVAL=3600
CONFIG_SPOTFLOW_METRICS_HEARTBEAT_INTERVAL=3600

System metrics use separate collection and aggregation intervals:

prj.conf — reduced system metrics transmission
# Default: sample every 10 s, send aggregated every 60 s
CONFIG_SPOTFLOW_METRICS_SYSTEM_COLLECTION_INTERVAL=10
CONFIG_SPOTFLOW_METRICS_SYSTEM_AGGREGATION_INTERVAL=60

# Low-bandwidth: sample every 60 s, send aggregated every hour
CONFIG_SPOTFLOW_METRICS_SYSTEM_COLLECTION_INTERVAL=60
CONFIG_SPOTFLOW_METRICS_SYSTEM_AGGREGATION_INTERVAL=3600

CBOR encoding

The Spotflow device module uses CBOR (Concise Binary Object Representation) for all messages — logs, metrics, and coredumps. CBOR uses small numeric field identifiers and encodes values in their native binary form, which usually produces smaller payloads than text-based formats.

Compact logs

Compact logs for Zephyr are available in the Spotflow device module. The device sends a format string address and binary arguments; Spotflow reads the string from the matching firmware ELF file and formats the message in the cloud. This saves bandwidth and skips text formatting in the device's logging backend. You must upload the matching ELF file as a symbol file in Firmware Management for the logs to resolve.

The examples below compare complete Zephyr log messages encoded as CBOR. Each includes the message type, sequence number, severity, device uptime, a source label, and the body. The formatted version sends the final text without a separate format template (CONFIG_SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATE=n); the compact version sends a format string address, raw arguments, and addresses of both the source label key and its constant value.

Zephyr log callFormatted logCompact logReduction
LOG_INF("Temperature %d C, pressure %u hPa", 23, 1012)74 bytes43 bytes42%
LOG_WRN("Sensor %u failed after %u retries; check power supply", 7, 3)90 bytes43 bytes52%
LOG_INF("Door opened by %s after %u ms", user_name, 250) with a runtime user name of Maria72 bytes53 bytes26%
Stacked bars compare each example on the same byte scale. Every message has 12 bytes of shared fields. The source label uses 24 bytes as text or 13 bytes as ELF addresses. Formatted messages add 38, 54, or 36 bytes of text. Compact messages add 7 bytes for the format address and 11 bytes of arguments; the door example adds 10 bytes for the embedded name. Totals are 74 versus 43, 90 versus 43, and 72 versus 53 bytes.
Breakdown of the formatted and compact CBOR message sizes for the three examples above.

The counts assume definite-length CBOR maps, addresses encoded as 32-bit unsigned integers, four-byte argument slots, sequence number 0, uptime 1000 ms, and the 14-character source name device_monitor for all three examples. The last row sends the runtime name Maria in the compact log's embedded-string field, so its saving is smaller. Constant %s strings can instead be read from the ELF file. See the protocol and format reference for the CBOR fields and argument layout.

To also save firmware flash, enable format string stripping.

Power and Radio Activity

Longer aggregation windows and a longer heartbeat interval directly reduce the number of MQTT publishes per day, which is the primary factor in radio-on time for cellular and low-power wireless devices.

The design choices that reduce radio overhead by default:

  • QoS 0: The device module always uses MQTT QoS 0. No acknowledgment round-trips and no retransmit state machine mean fewer radio transactions per message.
  • Aggregation: A single aggregated MQTT message replaces hundreds of raw samples. With 1-hour aggregation, a metric that is sampled every 10 seconds produces one transmission instead of 360.
  • Heartbeat: The heartbeat interval controls how often the uptime metric is sent. Extending it from the default 60 seconds to 3600 seconds eliminates 59 out of every 60 heartbeat transmissions.

The configuration in the Metrics aggregation section above covers all the relevant options.

CPU Overhead

The MQTT processing runs on a dedicated background thread at K_LOWEST_APPLICATION_THREAD_PRIO by default (priority 14). This means application threads preempt it freely, and it only runs when no application work is pending. No additional configuration is needed to achieve this behavior.

The main tuning lever for CPU overhead is the system metrics collection interval. Sampling heap, stack, CPU utilization, and network counters has a small but nonzero cost. Increasing the interval reduces that overhead:

prj.conf
# Default: collect system metrics every 10 seconds
CONFIG_SPOTFLOW_METRICS_SYSTEM_COLLECTION_INTERVAL=10

# Reduced overhead: collect every 60 seconds
CONFIG_SPOTFLOW_METRICS_SYSTEM_COLLECTION_INTERVAL=60

CPU utilization metrics (SPOTFLOW_METRICS_SYSTEM_CPU) depend on Zephyr's CPU_LOAD subsystem, which is only available on single-core (non-SMP) targets: Cortex-M, RISC-V, and Cortex-A. On SMP builds, disable this metric to avoid a build error.

Offline Operation

The logging backend stores messages in a circular queue in RAM. When the device loses connectivity, messages continue to accumulate in the queue. When the connection is restored, the processing thread drains the queue and transmits buffered messages automatically, no application code is required.

When the queue is full and the device is still offline, the oldest messages are overwritten. This newest-wins eviction policy preserves the most recent context at the cost of older entries. Queue depth is controlled by SPOTFLOW_LOG_BACKEND_QUEUE_SIZE.

The log buffer is volatile. If the device reboots while offline, buffered logs are lost. Only coredumps survive reboots, they are stored in a dedicated flash partition and uploaded on the next boot once connectivity is available. Metrics behave similarly: aggregated values are held in RAM within the current aggregation window. If the device reboots mid-window, the partial aggregation is discarded.

Enabling Only What You Need

Metrics and coredumps are disabled by default. Only enable subsystems you actively use:

SubsystemEnable withDefault heap
LoggingCONFIG_SPOTFLOW_LOG_BACKEND=y (auto-enabled)6 KB
Custom metricsCONFIG_SPOTFLOW_METRICS=y8 KB
System metricsCONFIG_SPOTFLOW_METRICS_SYSTEM=y20 KB
CoredumpsCONFIG_SPOTFLOW_COREDUMPS=y18 KB

Within system metrics, each metric type can be toggled independently:

prj.conf — disable unused system metrics
CONFIG_SPOTFLOW_METRICS_SYSTEM_HEAP=y
CONFIG_SPOTFLOW_METRICS_SYSTEM_NETWORK=y
CONFIG_SPOTFLOW_METRICS_SYSTEM_CPU=y
CONFIG_SPOTFLOW_METRICS_SYSTEM_CONNECTION=y
CONFIG_SPOTFLOW_METRICS_SYSTEM_RESET_CAUSE=y
# Disable stack monitoring if thread count is high and heap is tight
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK=n

Disabling SPOTFLOW_METRICS_SYSTEM_STACK removes the dominant cost of the system metrics heap (roughly 15 KB of the 20 KB default).

Also disable IPv6 if your network stack does not require it, this is a common memory saving in Zephyr samples that applies regardless of Spotflow:

prj.conf
CONFIG_NET_IPV6=n

Minimum Footprint Configuration

The following is a starting-point prj.conf for a constrained device using logging only. Adjust queue size, encoded message length, and log level to match your application's production logging requirements.

prj.conf — minimum footprint (logging only)
# Enable Spotflow
CONFIG_SPOTFLOW=y

# Reduce logging heap: ~1 KB instead of default 6 KB
CONFIG_SPOTFLOW_LOG_BACKEND_QUEUE_SIZE=4
CONFIG_SPOTFLOW_LOG_BUFFER_SIZE=128
CONFIG_SPOTFLOW_CBOR_LOG_MAX_LEN=160
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_LOGGING=1024

# Send INFO and above by default
CONFIG_SPOTFLOW_DEFAULT_SENT_LOG_LEVEL=3

# Suppress SDK internal debug output
CONFIG_SPOTFLOW_MODULE_DEFAULT_LOG_LEVEL=3

# Omit format template from log messages to reduce payload size
CONFIG_SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATE=n

# IPv4 only
CONFIG_NET_IPV6=n

For a device that also uses metrics, extend with:

prj.conf — metrics additions for constrained devices
CONFIG_SPOTFLOW_METRICS=y
CONFIG_SPOTFLOW_METRICS_SYSTEM=y

# Aggregate metrics hourly instead of every minute
CONFIG_SPOTFLOW_METRICS_DEFAULT_AGGREGATION_INTERVAL=3600
CONFIG_SPOTFLOW_METRICS_HEARTBEAT_INTERVAL=3600

# Reduce system metrics heap by limiting thread tracking
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_ALL_THREADS=n
CONFIG_SPOTFLOW_METRICS_SYSTEM_STACK_MAX_THREADS=4
CONFIG_HEAP_MEM_POOL_ADD_SIZE_SPOTFLOW_METRICS_SYSTEM=8192

# Sample system metrics less frequently
CONFIG_SPOTFLOW_METRICS_SYSTEM_COLLECTION_INTERVAL=60
CONFIG_SPOTFLOW_METRICS_SYSTEM_AGGREGATION_INTERVAL=3600

# Trim label slots to actual usage (saves ~48 bytes per timeseries per unused label)
CONFIG_SPOTFLOW_METRICS_MAX_LABELS_PER_METRIC=1

To further reduce bandwidth, device-side formatting work, and firmware flash usage, enable compact logs and format string stripping with the block below. This disables UART log output and requires uploading the matching firmware ELF file as a symbol file in Firmware Management. Remove the SPOTFLOW_LOG_BUFFER_SIZE and SPOTFLOW_LOG_INCLUDE_BODY_TEMPLATE settings above, which are unavailable with compact logs.

prj.conf — compact logs and format string stripping
CONFIG_SPOTFLOW_COMPACT_LOGS=y
CONFIG_LOG_BACKEND_UART=n
CONFIG_LOG_OUTPUT=n
CONFIG_LOG_FMT_SECTION=y
CONFIG_LOG_FMT_SECTION_STRIP=y

Learn more

On this page