Black-box flight recorder logs for post-crash analysis
The most useful minute of logs on a field device is the minute before it stopped working, and it is the one minute conventional logging reliably loses: it was buffered, or the log file was rotated, or the process died before it flushed. A flight recorder is a small circular file written with the same crash-safety discipline as a spool, holding the last few thousand structured events, readable after any restart. This guide builds one, inside field diagnostics and recovery and the Edge Operations & Observability guide.
Why not just log to a file
Ordinary logging fails at exactly this task for four independent reasons, and fixing any one of them does not help.
It is buffered. A logging handler writes into a buffer flushed on a boundary; a process killed by the OOM reaper takes that buffer with it, which is precisely the incident being investigated.
It is unbounded, then rotated. Rotation keeps size under control by discarding whole files, and the file discarded is usually the one that spans the interesting moment.
It is expensive. Formatting a string, acquiring a lock and writing it costs tens of microseconds and allocates, which is why the noisy paths — the ones you most want a record of — are usually logged at a level that is disabled.
It is not structured. Reconstructing a sequence of state transitions from prose lines requires parsing text written by someone who did not anticipate the question.
A flight recorder inverts every one of those: fixed-size binary records, a bounded circular file, an append that costs under a microsecond, and a schema that makes the sequence machine-readable.
The recorder
# recorder.py — fixed-size circular event recorder.
# 32-byte records in a preallocated file, mmap'd, with an explicit head index.
# Append costs one struct.pack_into and one integer increment: no allocation,
# no lock beyond a cheap one, safe to call from the hot path.
import mmap
import os
import struct
import time
import zlib
REC = struct.Struct("<IHHQiiI") # crc, kind, code, mono_ns, a, b, seq
REC_LEN = REC.size # 32 bytes
CAPACITY = 8192 # 256 KB of file
HEADER = struct.Struct("<4sIII") # magic, version, capacity, head
HEADER_LEN = 32 # padded
class Recorder:
__slots__ = ("mm", "fh", "head", "seq", "sync_every", "since_sync")
def __init__(self, path: str, sync_every: int = 64):
size = HEADER_LEN + CAPACITY * REC_LEN
new = not os.path.exists(path) or os.path.getsize(path) != size
self.fh = open(path, "r+b" if not new else "w+b")
if new:
self.fh.truncate(size)
self.mm = mmap.mmap(self.fh.fileno(), size)
magic, version, cap, head = HEADER.unpack_from(self.mm, 0)
if new or magic != b"FLTR" or cap != CAPACITY:
HEADER.pack_into(self.mm, 0, b"FLTR", 1, CAPACITY, 0)
head = 0
self.head = head
self.seq = 0
self.sync_every = sync_every
self.since_sync = 0
def record(self, kind: int, code: int, a: int = 0, b: int = 0) -> None:
"""One event. `kind` is a subsystem, `code` an event within it, and
`a`/`b` are two integers whose meaning is defined per (kind, code) —
deliberately not a string, because formatting is what makes logging
expensive enough to disable."""
off = HEADER_LEN + (self.head % CAPACITY) * REC_LEN
mono = time.monotonic_ns()
self.seq += 1
body = struct.pack("<HHQiiI", kind, code, mono, a, b, self.seq)
crc = zlib.crc32(body) & 0xFFFFFFFF
REC.pack_into(self.mm, off, crc, kind, code, mono, a, b, self.seq)
self.head += 1
HEADER.pack_into(self.mm, 0, b"FLTR", 1, CAPACITY, self.head)
self.since_sync += 1
if self.since_sync >= self.sync_every:
# Flush the dirty pages. On a 256 KB mapping this is a handful of
# pages and costs about 200 µs, amortised over 64 records.
self.mm.flush()
self.since_sync = 0
def dump(self):
"""Yield records oldest-first. Skips slots whose CRC does not verify,
which is how a torn write during a power cut is handled."""
_magic, _v, cap, head = HEADER.unpack_from(self.mm, 0)
start = max(0, head - cap)
for i in range(start, head):
off = HEADER_LEN + (i % cap) * REC_LEN
crc, kind, code, mono, a, b, seq = REC.unpack_from(self.mm, off)
body = struct.pack("<HHQiiI", kind, code, mono, a, b, seq)
if (zlib.crc32(body) & 0xFFFFFFFF) != crc:
continue
yield {"kind": kind, "code": code, "mono_ns": mono,
"a": a, "b": b, "seq": seq}
Two decisions define the design. The record carries integers rather than text, so an append is a pack into a mapped page rather than a format, a lock and a write. And the head index is updated in the header after the record, so a crash between the two leaves a complete record that the next dump will read — the ordering is deliberately biased toward keeping data rather than toward a perfectly consistent counter.
Making integers readable
A recorder full of kind=3 code=17 a=482 b=0 is only useful if something can name those. The decoder lives off-device, in the same repository as the firmware, and is versioned with it.
# decode.py — the schema that makes a dump readable. Ships with the tooling,
# not with the device: the device's job is to record, not to explain.
KINDS = {1: "pipeline", 2: "spool", 3: "uplink", 4: "power", 5: "geo"}
EVENTS = {
(1, 1): ("fix_parsed", "sats={a} hdop_x10={b}"),
(1, 2): ("fix_rejected", "reason={a}"),
(1, 9): ("worker_stalled", "ms={a}"),
(2, 1): ("spool_append", "partition={a} bytes={b}"),
(2, 7): ("spool_dropped", "partition={a} count={b}"),
(3, 1): ("uplink_connected", "rssi_dbm={a}"),
(3, 4): ("uplink_failed", "errno={a} attempt={b}"),
(4, 2): ("brownout_detected", "mv={a}"),
(5, 3): ("fence_crossed", "zone={a} inside={b}"),
}
def render(rec: dict) -> str:
name, fmt = EVENTS.get((rec["kind"], rec["code"]), ("unknown", "a={a} b={b}"))
return (f"{rec['mono_ns'] / 1e9:12.3f} {KINDS.get(rec['kind'], '?'):9s} "
f"{name:18s} " + fmt.format(a=rec["a"], b=rec["b"]))
Keeping the schema off-device is what allows the record to stay at 32 bytes. It also means a decoder can be improved after the fact — a dump collected last year can be re-rendered with today’s understanding, which is not possible when the device baked its interpretation into a text string.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| CPU | A recorder too expensive to call is not called | Integer pack into a mapped page; under a microsecond, no allocation |
| Flash | Continuous writing wears the card | A fixed 256 KB region rewritten in place; the flush is amortised over 64 records |
| Power loss | A torn record must not break the dump | Per-record CRC; dump skips what does not verify |
| RAM | Must not grow with event volume | A fixed mapping; the file is the buffer |
| Restart | The record must survive the crash being investigated | Mapped file with periodic flush; a process crash loses nothing |
Gotchas and edge cases
mmap.flush()is not free and is not per record. Flushing on every append turns a sub-microsecond call into a syscall and a page write. Amortise it, and accept the bounded loss on a power cut — the process-crash case, which is the common one, loses nothing regardless.- Monotonic time does not cross a reboot. The dump from before a restart cannot be interleaved with the one after by timestamp. Record a boot marker as the first event after every start, and treat each boot’s records as an independent sequence.
- Do not record inside the recorder’s own failure path. A recorder that logs its own errors through itself will fill the file with them during exactly the fault you are trying to capture. Fail silently and expose a counter instead.
- Reserve the low kind and code numbers. A schema that renumbers events between firmware versions makes historical dumps unreadable. Treat
(kind, code)pairs as permanent once shipped; add new ones rather than reusing old ones. - The dump has to be reachable without a network. Expose it through the serial console described in serial console health endpoints, and as a file a technician can copy to a USB stick. A recorder that can only be read by uploading it is useless in the case where the uplink is the fault.
Using it
The value shows up in three situations. After an unexplained restart, the last records before the boot marker say what the device was doing — a stalled worker, a spool that filled, a brownout. After a field report of odd behaviour, the sequence of state transitions distinguishes a device that behaved correctly from one that did not, without reproducing anything. And during a rollout, comparing dumps from a device that took an update badly against one that took it well isolates the difference far faster than reasoning about the diff.
Sync a compressed dump on demand rather than continuously: a command that requests it, and the device attaches the last N records to the event stream. At 32 bytes a record, 2 000 records compress to a few kilobytes, which is affordable on any link when someone has asked for it and unaffordable as a routine push.
Choosing what to record
A recorder that captures everything fills with noise, and one that captures only errors misses the sequence that explains them. The useful discipline is to record state transitions and decisions, not activity.
Record when a component changes state — the pipeline entering degraded mode, the uplink connecting or dropping, a partition starting to drop records, the thermal governor throttling. Record when a decision is taken that a human might later question: a fix rejected, a zone crossing suppressed because uncertainty was too high, a bundle refused. And record the boundaries — boot, shutdown, configuration reload, layer swap — because they anchor everything between them.
Do not record per-fix activity at full rate. A device processing forty fixes a second and recording each one exhausts a 8 192-slot buffer in three minutes, which is exactly the history someone will want. Record a periodic summary instead — one record a second carrying the counts — and let the individual events stay in the metrics.
Related
- Field Diagnostics & Recovery — the wider diagnostic layer this is the deepest part of.
- Serial console health endpoints for field techs — how a technician reaches this dump on site.
- Crash-safe append logs with fsync budgets — the same durability trade applied to the data path.