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:

FieldWhat
scopeLabels shared by every series in the batch. Declared once.
symbolsString intern table for the wire form. Auto-built.
seriesThe list of series.
ati64 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 like http_requests_total intact; digit-leading segments keep nanook-native ids like load.1m, load.5m, http.5xx intact.
  • kind: collection semantics. One of counter, gauge, hist, summary, info, stateset, event.
  • unit: one of raw, 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, or Summary depending 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

@scope host=node-1 env=prod
@at 1714900000000000000

@help http.requests.total "total HTTP requests"
@unit http.requests.total count
@type http.requests.total counter
http.requests.total{route="/users"} 1234 @t=1714900000000000001

@type http.duration hist
@unit http.duration seconds
http.duration{route="/users"} hist{
  buckets=[0.005:12, 0.01:34, +Inf:200]
  sum=4.21 count=246
  @t=1714900000000000001
}

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:

DirectiveMeaning
@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)
curl http://node-1:9091/pulse

# compact binary, decoded locally
curl http://node-1:9091/pulse?format=wire | nanook pulse decode -

# or via header
curl -H 'Accept: application/vnd.nanook.pulse' http://node-1:9091/pulse
FormQueryAcceptContent-Type
Text?format=text or omittedanytext/plain; charset=utf-8; format=pulse
Wire?format=wireapplication/vnd.nanook.pulseapplication/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
nanook pulse decode metrics.pulse

# encode text back to bytes (writes to stdout)
nanook pulse encode metrics.txt > metrics.pulse

# scrape a running adapter and pretty-print
nanook pulse cat http://node-1:9091/pulse

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 Name grammar 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 info kind: a constant 1 point with the original text in a value= label, mirroring the Prometheus *_info{...} 1 convention.

Errors

Every parse failure is a structured miette diagnostic with an exact byte-range label:

  • nanook::pulse::unknown_directive: @foo not recognized.
  • nanook::pulse::bad_at: @at value 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 (missing buckets, 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.