pulse
nanook's metric format: data model, text and binary encodings.
pulse is nanook's metric format. Two encodings round-trip the same data model: a human-readable text form and a compact binary frame.
The nanook-pulse crate ships the data model and both codecs. The built-in pulse adapter serves a snapshot over HTTP in either form.
Data model
A Batch is the top-level container. Each batch carries:
| Field | What |
|---|---|
scope | Labels shared by every series in the batch. Declared once. |
symbols | String intern table for the wire form. Auto-built. |
series | The list of series. |
at | i64 nanoseconds since Unix epoch, when the batch was produced. |
Each Series has:
name: dotted identifier, segments matching[a-z0-9_]+. Underscores keep Prometheus-style names likehttp_requests_totalintact; digit-leading segments keep nanook-native ids likeload.1m,load.5m,http.5xxintact.kind: collection semantics. One ofcounter,gauge,hist,summary,info,stateset,event.unit: one ofraw,percent,count,bytes,bps,seconds,celsius.help: optional human-readable description.labels: per-series labels stacked on top of the batch scope.body:Points,Hist, orSummarydepending on kind.
A Point is { val, ts, exemplar? }. Values are tagged: F64(f64), I64(i64), U64(u64) so integer counters stay lossless.
A Hist is sparse buckets [(le, count), ...] plus sum and count, in one self-contained block.
A Summary is [(q, val), ...] plus sum and count.
Text format
http.requests.total 1234 =
http.duration hist
http.duration carries the unit via @unit seconds, no _seconds suffix needed. Same for _total, _bytes, and friends: pulse pulls the dimension out of the name into the unit tag.
Directives all start with @ and carry one piece of information per line:
| Directive | Meaning |
|---|---|
@scope key=value ... | Labels shared by every series in this batch. |
@at <ns> | Batch timestamp, i64 nanoseconds since Unix epoch. |
@help <name> "<text>" | Human-readable description of a series. |
@unit <name> <unit> | Unit tag (one of the seven canonical units). |
@type <name> <kind> | Collection kind. Creates the series if absent. |
Sample lines are <name>{<labels>?} <value> [@t=<ns>]. Histogram and summary blocks open with hist{ / summary{ and close with }; whitespace inside is free-form.
Lines starting with # are comments. Blank lines are ignored.
Binary (wire) format
Same data model, framed binary. Layout:
┌────────┬──────┬─────────────┬───────────────────────────┐
│ offset │ size │ field │ value │
├────────┼──────┼─────────────┼───────────────────────────┤
│ 0 │ 4 │ magic │ "VPF1" │
│ 4 │ 1 │ version │ 1 │
│ 5 │ 1 │ flags │ reserved (0) │
│ 6 │ 2 │ pad │ (0, 0) │
│ 8 │ 4 │ payload_len │ u32 LE │
│ 12 │ N │ payload │ see body layout │
│ 12+N-4 │ 4 │ crc32 │ IEEE, over payload[..N-4] │
└────────┴──────┴─────────────┴───────────────────────────┘
The payload is LEB128-varint heavy: counts, symbol indexes, integer fields. Timestamps within a Points body are delta-encoded (zigzag varint) against the previous point in the series. Strings live once in the symbol table; series labels and names reference them by id.
For a typical scrape body, the wire form is roughly 5 to 10x smaller than the equivalent Prometheus text exposition.
Pulse over HTTP
The built-in pulse adapter exposes the snapshot as a single batch per request. Content negotiated by query string or Accept header:
# human-readable text (default)
# compact binary, decoded locally
|
# or via header
| Form | Query | Accept | Content-Type |
|---|---|---|---|
| Text | ?format=text or omitted | any | text/plain; charset=utf-8; format=pulse |
| Wire | ?format=wire | application/vnd.nanook.pulse | application/vnd.nanook.pulse |
See adapters for full configuration options.
CLI utilities
nanook pulse <op> ships three local utilities for working with frames offline:
# decode a binary frame into canonical text
# encode text back to bytes (writes to stdout)
# scrape a running adapter and pretty-print
Pass - as the path to read from stdin.
Translating from existing metrics
When the pulse adapter renders a snapshot of in-memory Metric values:
- Names that don't match the pulse
Namegrammar are dropped with a warning. Most existing dotted or underscored names pass through unchanged. - Numeric values map directly:
Float -> F64,Int -> I64,Bool -> U64(0|1),Enum -> F64(value). - Text-valued metrics become the
infokind: a constant1point with the original text in avalue=label, mirroring the Prometheus*_info{...} 1convention.
Errors
Every parse failure is a structured miette diagnostic with an exact byte-range label:
nanook::pulse::unknown_directive:@foonot recognized.nanook::pulse::bad_at:@atvalue not an i64.nanook::pulse::unknown_unit/unknown_kind: unit or kind not in the canonical set.nanook::pulse::bad_value: sample value not a number.nanook::pulse::bad_timestamp:@t=value not an i64.nanook::pulse::bad_hist/bad_summary: block payload malformed (missingbuckets,sum,count, etc.).nanook::pulse::text_bad_name: metric name doesn't match the grammar.nanook::pulse::wire_bad_magic/wire_unknown_version/wire_bad_crc: binary frame is not a pulse frame, is from a future encoder, or is corrupt.
Every code resolves to a doc URL under https://nanook.cstef.dev/errors/... automatically.