Concepts
Metric Broker
Store and stream measurements over time. Groups, Tags and addresses instead of topic names, the 105 dtypes, discrete values, interpolation, two wire formats, delivery and retention.
The Metric Broker stores and streams measurements over time: temperatures, speeds, levels,
counts. Each series is a set of points x → y, usually a time and a number. Devices publish over a
compact binary WebSocket or its JSON twin; dashboards receive the points live; and REST
reads the history back: the stored points, a resampled curve, or the value at any moment. Data is
kept for as many days as you choose, with no practical limit.
It is a broker of its own, with its own claims, routes and WebSockets. Unlike the other brokers, it
has no topic names: a series is addressed by two numbers, a Group and a Tag, so a measurement
fits in a few bytes. In JSON and REST they are written as one hex address, "000C:3E01".
Series of points#
| Term | Meaning |
|---|---|
| Topic | One series of points x → y, at an address: a Group and a Tag. One y per x. |
| Measurement | A stored point (x, y). Delivered live as meas, read back over REST. |
| Estimate | A y computed at any x from the stored points, by the Tag's interpolation. REST only. |
Sending a point for the same Group, Tag and x again replaces its y: a series never holds two
values at the same x.
Groups and Tags#
A Topic is 4 bytes on the wire: a Group and a Tag, both unsigned 16-bit numbers.
| Concept | Size | Meaning |
|---|---|---|
| Group | u16 | 0–65535, a number you choose. The unit of permissions and live delivery. |
| Tag | u16 | 0–65535: a dtype (high byte) and an id (low byte). One series. |
The dtype is part of the Tag, so the broker knows the type and size of x and y from the address
alone, and series of different types never mix:
bit 15 14 13 12 │ 11 10 9 8 │ 7 6 5 4 3 2 1 0
x x x x │ k k w w │ i i i i i i i i
└─ x digit ┘ └─ y digit ┘ └──────── id ─────────┘
└──────── dtype ────────┘
dtype = tag >> 8 x = tag >> 12 y = (tag >> 8) & 0xF id = tag & 0xFFFor example, Tag 3E01 (decimal 15873) is 0011 1110 0000 0001 in bits: x digit 3 (time set by
the server, in nanoseconds), y digit E (a 32-bit float), id 1. Its dtype is 3E, named
sns_f32.
| Per | Count |
|---|---|
| Groups per Domain | 65 536 |
| dtypes | 256 (105 valid) |
| ids per dtype in a Group | 256 |
| Tags per Group | 65 536 |
Nothing needs registering. Publishing to a Topic nobody used before stores the point. You may give a Tag a name, an interpolation and a discrimination later; that metadata belongs to one Tag in one Group and is never shared between Groups.
Group numbers are yours to plan. Permissions are granted on ranges of Groups (for example
0064–00C7, decimal 100–199, for one plant), so number Groups by audience: whoever needs a set of
series should find them in one range. See
Claims and permissions.
Addresses#
A Group and a Tag, that is a Group, a dtype and an id, are the address of an instrument: one
series. In JSON (the json WebSocket and REST) and in REST paths, Groups, Tags and addresses are
hex text, most significant digit first:
| Value | Text form | Example | Used for |
|---|---|---|---|
| address | GGGG:TTTT (4 + 4 hex digits) | "000C:3E01" | One series: pub, sub, unsub, their replies, meas, REST publish, query results, the audit log, the Tag registry. |
| Group | GGGG (4 hex digits) | "000C" | A whole Group: sub-group, unsub-group, claim ranges, scoped Tokens, the Group registry, purge, query filters. |
| Tag | TTTT (4 hex digits) | "3E01" | Query filters (tags) only. |
"000C:3E01" Group 000C (12) · Tag 3E01: x digit 3 (sns), y digit E (f32), id 01- Exactly 4 digits per part, leading zeros kept (
"0400", not"400"), so a Tag's first two digits are always its dtype:3E01reads assns_f32, id01, at a glance. - Either case is accepted (
"000c:3e01"); Thuum always writes uppercase. - One form only: no
0x, no decimal, no numbers. Anything else is refused (bad_requeston the WebSocket,422over REST), with a message such asmust be an address: 4 + 4 hex digits, e.g. "000C:3E01". - The binary encoding doesn't change: Group and Tag are two little-endian
u16, so"000C:3E01"travels as the 4 bytes0C 00 01 3E.
Queries are the one place where Groups and Tags are given separately, as two lists: groups
(required) and tags (optional), each of single values and [from, to] ranges. Together they select
every selected Tag in every selected Group, which a range of addresses can't express: groups: [["0064", "00C7"]] with tags: [["3E00", "3EFF"]] is every sns_f32 series of Groups 100 to 199.
Datatypes#
A dtype is two hex digits: the first says what x is, the second what y is. All multi-byte values
are little-endian; floats are IEEE 754.
x: the first digit#
| Digit | Name | x | Set by | Bytes in pub | Bytes in meas, pub:ok |
|---|---|---|---|---|---|
0 | smin | time, ns, rounded down to the minute | server | 0 | 8 (u64) |
1 | ssec | time, ns, rounded down to the second | server | 0 | 8 (u64) |
2 | sms | time, ns, rounded down to the ms | server | 0 | 8 (u64) |
3 | sns | time, ns | server | 0 | 8 (u64) |
4 | cts | time, ns (u64) | client | 8 | 8 |
5 | f16 | half-precision float | client | 2 | 2 |
6 | f32 | single-precision float | client | 4 | 4 |
7–F | — | reserved |
Times are nanoseconds since the Unix epoch, from 0 to 2^63 − 1 (1970 to 2262). On the
binary WebSocket a time is 8 bytes; in JSON (the json WebSocket and REST) it is an ISO 8601
string with up to 9 decimals, "2026-11-02T10:00:00.5Z", and a float x is a JSON number.
- Server time (
0–3): thepubcarries nox. The server sets it when the measurement arrives, rounded down to the digit's precision, and sends it back inpub:okand in everymeas. Severalpubs on one Tag within the same minute (smin), second (ssec) or millisecond (sms) get the samex, so the last one wins:sminkeeps one value per minute without any work on the device. - Client time (
4): the device sends its own timestamp. Use it for readings buffered offline and sent later, or when the time of the reading matters more than the time of arrival. - Float x (
5,6): series wherexisn't a time, such as a profile along a distance or a calibration curve.
y: the second digit#
y is set by the client. Its digit is kk ww in bits: kk the kind, ww the width, and the size
is 1 << ww bytes.
| Digit | Name | y | Bytes | In JSON |
|---|---|---|---|---|
0 | d8 | discrete key, unsigned | 1 | integer (REST queries: the item's text) |
1 | d16 | discrete key, unsigned | 2 | integer (REST queries: the item's text) |
2 | d32 | discrete key, unsigned | 4 | integer (REST queries: the item's text) |
3 | d64 | discrete key, unsigned | 8 | decimal string, always (REST queries: the item's text) |
4 | u8 | unsigned integer | 1 | integer |
5 | u16 | unsigned integer | 2 | integer |
6 | u32 | unsigned integer | 4 | integer |
7 | u64 | unsigned integer | 8 | decimal string, always: "18446744073709551615" |
8 | i8 | signed integer | 1 | integer |
9 | i16 | signed integer | 2 | integer |
A | i32 | signed integer | 4 | integer |
B | i64 | signed integer | 8 | decimal string, always: "-9223372036854775808" |
C | — | reserved | ||
D | f16 | half-precision float | 2 | number, rounded to the nearest f16 |
E | f32 | single-precision float | 4 | number, rounded to the nearest f32 |
F | f64 | double-precision float | 8 | number |
The JSON forms are the same on the json WebSocket and over REST. 64-bit integers are strings
because many JSON parsers, JavaScript's included, read numbers as doubles, exact only up to 2^53;
everything else fits a double exactly. The details, and why, are in
Values in JSON.
All dtypes#
A dtype is valid when its x digit is 0–6 and its y digit isn't C: 7 × 15 = 105 dtypes.
Anything else is answered with bad_request. A dtype's name is {x}_{y}:
| x \ y | 0–3 discrete | 4–7 unsigned | 8–B signed | D–F float |
|---|---|---|---|---|
0 smin | 00–03 | 04–07 | 08–0B | 0D–0F |
1 ssec | 10–13 | 14–17 | 18–1B | 1D–1F |
2 sms | 20–23 | 24–27 | 28–2B | 2D–2F |
3 sns | 30–33 | 34–37 | 38–3B | 3D–3F |
4 cts | 40–43 | 44–47 | 48–4B | 4D–4F |
5 f16 | 50–53 | 54–57 | 58–5B | 5D–5F |
6 f32 | 60–63 | 64–67 | 68–6B | 6D–6F |
| Tag | Decimal | dtype | Reads as |
|---|---|---|---|
3E01 | 15873 | sns_f32 | server time in ns, a 32-bit float; id 1 |
3000 | 12288 | sns_d8 | server time in ns, a discrete 8-bit key; id 0 |
4F01 | 20225 | cts_f64 | the client's time in ns, a 64-bit float; id 1 |
5407 | 21511 | f16_u8 | a half-precision float x, an unsigned 8-bit y; id 7 |
NaN, +Inf and -Inf are refused (bad_request), in x and in y.
Discrete values#
A discrete y (d8 to d64) is a key into a discrimination: a dictionary of key → text,
such as 1 → HOT, 2 → COLD. Each Tag (in its Group) links to at most one discrimination; the
same discrimination can be linked to many Tags. On the WebSocket the key travels as the Tag's
integer (bytes, or JSON: a number, a decimal string for d64); REST queries answer with the text,
or the key itself, written the same way, if the discrimination has no item for it. Items take
effect at once: change a text and the next query returns it.
Discriminations are managed over REST (Metric Broker REST) or in the console, Metric Broker → Discriminations.
Interpolation#
Each Tag has a strategy to estimate y where nothing was measured. It is metadata: change it at any
time, and every later estimate follows.
| Strategy | Behaviour | Valid for |
|---|---|---|
step-hold (default) | The last value at or before x. | every Tag; the only one for discrete Tags |
linear | The straight line between the point before and the point after x. | unsigned, signed, float |
spline | A monotone cubic curve over up to two points on each side. Never overshoots the readings. | unsigned, signed, float |
Estimates (the REST resample and at queries) use the neighbouring stored points, inside or
outside the requested range:
- a point stored exactly at
xgives itsy; - nothing stored at or before
xgivesnull; - nothing stored after
xgives the last value before it, whatever the strategy; splinewith only one point on each side islinear;- estimates of numeric
yare floats, so JSON numbers, even when a point is stored exactly atx(au64stored as"18446744073709551615"estimates as1.8446744073709552e19: userawfor exact 64-bit values); discrete Tags keep their key, rendered as its item's text (else the key).
Publish and subscribe#
Every connection picks an encoding by its URL: …/metric/connect/bytes (binary frames) or
…/metric/connect/json (JSON text, with x and y as plain JSON values). Both carry the same
operations, and a measurement published over one reaches subscribers of the other unchanged.
→ [{ "op": "sub-group", "group": "000C" }]
→ [{ "op": "pub", "address": "000C:3E01", "y": 23.5 }]
← [{ "op": "meas", "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 }]x is the time the server set, y the reading. Over bytes, the same pub is 11 bytes: y is
00 00 BC 41, 23.5 as a little-endian f32. In JSON, numbers are JSON numbers, but 64-bit
integers and times are strings, because many JSON parsers read every number as a double and would
silently round them: see Values in JSON.
With @thuum/client, MetricBrokerSocket does the encoding; times and 64-bit integers are
bigint:
import { formatTime } from '@thuum/client';
const socket = acme.metricBroker.connect({ encoding: 'bytes' });
socket.onMeas(({ address, x, y }) => console.log(address, formatTime(x as bigint), y));
await socket.ready;
await socket.subGroup('000C', { ack: true }); // needs S
await socket.pub({ address: '000C:3E01', y: 23.5 }); // needs P; the server sets x| Op | Flag | Does |
|---|---|---|
pub | P | Store a measurement, then deliver it. |
sub | S | Receive the measurements of one address. |
sub-group | S | Receive the measurements of every Tag in a Group. |
unsub | — | Undo a sub. |
unsub-group | — | Undo a sub-group. |
Every op is fire-and-forget by default: no reply unless it fails. Add "ack": true (in bytes,
the op's ack code) to get an :ok reply. Errors are always sent. The frames, byte by byte, are on
Metric Broker WebSocket.
From REST, POST /api/{domain}/metric/data publishes up to 1000 measurements at once, stored and
delivered the same way.
Two formats, one broker#
The two encodings are two formats of one protocol, not two brokers:
- The format belongs to the connection. The WebSocket standard marks every frame as text or
binary; the Metric Broker ties that type to the connection: a
/bytesconnection takes and sends binary frames only, a/jsonconnection text frames only. Pushedmeasmessages therefore always arrive in the format the subscriber chose, with nothing to guess. Control frames (ping, pong, close) are unaffected; a data frame of the other type closes the connection with1003. - Decoded at the edge, one core. Each format is translated, as the frame arrives, into one
internal form of the ops. A single core then checks claims, stores, delivers, tracks subscriptions
and acks and writes the audit log, without knowing either format. Replies and deliveries are
encoded back for each connection in its own format, so a
bytespublisher reaches ajsonsubscriber, and the other way round, without losing anything. - Errors repeat what you sent. An
:errorreply echoes the op exactly as it arrived: inbytesits original bytes (anf32NaNsent as00 00 C0 7Fcomes back as00 00 C0 7F), in JSON its own fields as written. - Same checks, same order. Every op is checked in the same order in both formats: its
structure, then its dtype, then the claim, then its values. A malformed value sent
to a Group you may not publish to is answered
forbiddenin both.
The details are on Metric Broker WebSocket.
Query the history#
REST answers three kinds of questions about any set of Groups and Tags:
| Query | Answers |
|---|---|
raw | The stored points in a range, oldest or newest first, up to 10 000 per series. |
resample | A given number of evenly spaced estimates over a range: a chart's points. |
at | One estimate per series at one x: "what was the value at 03:12?" |
curl -s -X POST https://api.thuum.dev/api/acme/metric/query/resample \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"groups":["000C"],"tags":["3E01"],"from":"2026-11-02T10:00:00Z","until":"2026-11-02T11:00:00Z","points":4}'Each series in the answer is named by its address ("000C:3E01"), in address order. REST uses
the same JSON values: times as ISO 8601 UTC with up to 9 decimals, stored u64, i64 and d64
values as decimal strings, other y as numbers, a discrete Tag's item text, and estimates as
numbers. See Metric Broker REST.
Delivery guarantees#
- Stored first, then delivered. A
pub:ok(or a REST201) means the measurement is stored. The ops of one message are stored together. - Live delivery is best-effort. A subscriber that is disconnected misses what is published
meanwhile; nothing is queued for it. History is the recovery path: query
rawfrom the lastxyou received. - Everyone subscribed receives it, publisher included, and once per connection, even when it is subscribed to the Group and to the address.
- Together and in order. The measurements one message publishes to a Group reach each subscriber as one message, in publish order: one message per Group the publish touched.
- Safe retries with client
x. Re-sending a point with the samexreplaces it, so retrying acts,f16orf32publish never duplicates it. With server time, a retry is a new point at a new time (unless it falls in the same minute, second or millisecond). - Permissions are fixed at connect. A claim change applies to new connections.
Retention#
Measurements are kept per Group, for as many days as you choose: retention_days, any whole
number from 1 up, with no practical limit (the maximum, 1 000 000 days, is about 2 700 years): a
test Group for 7 days, a plant's readings for 400, an archive for 36 500 (a century).
| Setting | Value |
|---|---|
A Group's retention_days | 1 or more, or null: follow the Domain's defaults |
| Your Domain's defaults | retention_days 1 or more (required) and an optional max_points |
| The system default | 365 days, no cap: used when neither the Group nor the Domain sets one |
A Group may also cap its size with max_points: only its newest points are kept. A Group's window
applies as a whole: a Group with its own retention_days uses its own max_points (or none); a
Group without one follows your Domain's defaults, cap included, so max_points needs a
retention_days.
- Retention counts from when a point was stored, not from its
x: a reading with an old client timestamp, published today, is kept for a full period from today. - Old points are removed once a day, never while publishing: a point may outlive its retention by up to a day. Removals notify no one.
- To delete data on purpose, purge a Group (
000C) or one address (000C:3E01), optionally within a range (needsW).
Set retention with PUT /api/{domain}/metric/groups/{group} or your Domain's defaults, or in the
console (Metric Broker → Groups, a number of days): see
Retention and defaults.
Audit log#
Every :ok reply sent (ack forms) and every :error reply is also written to the Domain's audit
log: the op, its address (or its Group, for sub-group and unsub-group), x, and the error
code. Successful fire-and-forget ops aren't, so
ask for acks where you need a trail; JSON error replies (a message that isn't valid, an unknown
op) aren't either: they have no Group. Clients can't subscribe to it; read it with
GET /api/{domain}/metric/audit (needs the claim's audit_flags). Entries are written within about
a second and follow their Group's retention.
Try it#
The Playground's Metric Broker entry
(its guide) works like a programmer's calculator: build a
Tag, encode a value, compose or decode a frame, then publish and subscribe for real, with every byte
explained. The link below opens it in a new tab, in a sandbox Group of your own
(/playground/metric/000C, say):
With your own claims, use the console's Metric Broker pages (/me/metric/…): Live
publishes and subscribes in either encoding, Data queries and charts the history, Groups
and Discriminations edit the registry.
When to use another broker#
- Readings you may not store go over the Wire Broker: nothing is kept.
- The latest value only, as JSON (a device's configuration, a status), is a state on the State Broker.
- Things that happened, with a JSON payload (an alarm raised, a job finished), are events on the Event Broker.
Many apps combine them: a machine's readings on the Metric Broker, its current status on the State Broker, its alarms on the Event Broker.