dial9-trace-format
A binary trace format for tokio runtime telemetry, usable for any structured event stream.
See SPEC.md for the wire format specification.
Design principles
- Self-describing. Schemas are embedded in the stream so readers don't need out-of-band type definitions. No code generation or IDL compiler is needed to read a stream.
- Relatively compact. Events average ~15 bytes raw and ~3 bytes gzipped on a real-world tokio trace. The format isn't the absolute smallest possible; it trades a few bytes of overhead for the properties below.
- Extremely fast to write. The encoder does no allocations per event and uses fixed-width or LEB128 (variable-length integer) fields with no framing overhead beyond a 3-byte header. Benchmarks show ~48M events/s encode throughput on a single core.
- Compressible. Schemas are written once; events are pure data with no repeated field names or tags. Timestamps are delta-encoded as 3-byte offsets. This structure compresses well: gzip reduces a typical trace to ~20% of its raw size (and beats a hand-tuned bespoke format by 1.4x after compression).
- Simple enough to port. The entire wire format is ~5 frame types, LEB128 integers, and little-endian fixed-width fields. A JavaScript decoder is under 200 lines.
Numbers
On a 42k-event tokio runtime trace (format_comparison example, release mode):
| Raw | Gzipped | |
|---|---|---|
| dial9-trace-format | 632 KB (14.8 B/event) | 129 KB (3.0 B/event) |
| Hand-tuned bespoke format | 586 KB (13.7 B/event) | 177 KB (4.1 B/event) |
The self-describing format is ~8% larger raw but 37% smaller after gzip because its regular structure compresses better than the bespoke format's variable-length tag soup.
At 42k events per trace and a 1-second collection interval, a continuously-running agent produces roughly 11 GB/day raw or 2.3 GB/day gzipped.
Throughput (criterion, 1M mixed events, single core):
| Operation | Events/s |
|---|---|
| Encode | ~48M |
| Decode (visitor, zero-alloc) | ~30M |
| Decode (zero-copy ref) | ~7M |
| Decode (owned) | ~6M |
Usage
Derive macro
For event types known at compile time, use #[derive(TraceEvent)]:
use ;
use Encoder;
use ;
// Encode
let mut enc = new;
enc.write;
enc.write;
let bytes = enc.finish;
// Decode
let mut dec = new.unwrap;
for frame in dec.decode_all
The #[traceevent(timestamp)] attribute marks a u64 field as the event's timestamp. It is encoded as a u24 nanosecond delta in the event header (not as a regular field), giving nanosecond precision with no accumulation error. The encoder automatically emits TimestampReset frames when the delta exceeds ~16.7 ms.
Integer fields use fixed-width little-endian encoding (u8, u16, u32) or LEB128 (u64). The derive macro handles the mapping automatically.
Manual schema registration
For event types whose fields are determined at runtime (e.g., user-defined metrics, kernel tracepoints), register schemas by name:
use ;
use FieldDef;
use ;
let mut enc = new;
// Fields determined at runtime (e.g., from a config file)
let schema = enc.register_schema.unwrap;
// First value is always the timestamp (encoded in the event header)
enc.write_event.unwrap;
// Schemas are portable — pass the same handle to a different encoder
let mut enc2 = new;
enc2.write_event.unwrap;
String interning
Frequently-repeated strings can be interned to avoid encoding them multiple times:
use Encoder;
let mut enc = new;
let id = enc.intern_string;
// Use `id` in InternedString fields of subsequent events
Symbol table
For CPU profile stack frame symbolization, attach symbol data as schema-based events:
use Encoder;
use FieldDef;
use ;
;
let mut enc = new;
let name_id = enc.intern_string.unwrap;
enc..unwrap;
enc..unwrap;
JavaScript reader
A decode-only JS reader is at js/decode.js:
const = require;
const fs = require;
const dec = ;
dec.;
Field types
| Rust type | Wire type | Notes |
|---|---|---|
u8, u16, u32 |
Fixed | 1, 2, or 4 bytes LE |
u64 |
Varint | LEB128, 1–10 bytes |
i64 |
I64 | 8 bytes LE |
f64 |
F64 | 8 bytes LE |
bool |
Bool | 1 byte |
String |
String | u32 length + UTF-8 |
Vec<u8> |
Bytes | u32 length + raw |
StackFrames |
StackFrames | u32 count + u64 LE addresses |
Vec<(String, String)> |
StringMap | u32 count + key/value pairs |
PooledString (u32 pool ID) is available via manual schema registration.