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#

TermMeaning
TopicOne series of points x → y, at an address: a Group and a Tag. One y per x.
MeasurementA stored point (x, y). Delivered live as meas, read back over REST.
EstimateA 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.

ConceptSizeMeaning
Groupu160–65535, a number you choose. The unit of permissions and live delivery.
Tagu160–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:

Text
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 & 0xFF

For 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.

PerCount
Groups per Domain65 536
dtypes256 (105 valid)
ids per dtype in a Group256
Tags per Group65 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:

ValueText formExampleUsed for
addressGGGG: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.
GroupGGGG (4 hex digits)"000C"A whole Group: sub-group, unsub-group, claim ranges, scoped Tokens, the Group registry, purge, query filters.
TagTTTT (4 hex digits)"3E01"Query filters (tags) only.
Text
"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: 3E01 reads as sns_f32, id 01, 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_request on the WebSocket, 422 over REST), with a message such as must 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 bytes 0C 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#

DigitNamexSet byBytes in pubBytes in meas, pub:ok
0smintime, ns, rounded down to the minuteserver08 (u64)
1ssectime, ns, rounded down to the secondserver08 (u64)
2smstime, ns, rounded down to the msserver08 (u64)
3snstime, nsserver08 (u64)
4ctstime, ns (u64)client88
5f16half-precision floatclient22
6f32single-precision floatclient44
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): the pub carries no x. The server sets it when the measurement arrives, rounded down to the digit's precision, and sends it back in pub:ok and in every meas. Several pubs on one Tag within the same minute (smin), second (ssec) or millisecond (sms) get the same x, so the last one wins: smin keeps 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 where x isn'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.

DigitNameyBytesIn JSON
0d8discrete key, unsigned1integer (REST queries: the item's text)
1d16discrete key, unsigned2integer (REST queries: the item's text)
2d32discrete key, unsigned4integer (REST queries: the item's text)
3d64discrete key, unsigned8decimal string, always (REST queries: the item's text)
4u8unsigned integer1integer
5u16unsigned integer2integer
6u32unsigned integer4integer
7u64unsigned integer8decimal string, always: "18446744073709551615"
8i8signed integer1integer
9i16signed integer2integer
Ai32signed integer4integer
Bi64signed integer8decimal string, always: "-9223372036854775808"
C—reserved
Df16half-precision float2number, rounded to the nearest f16
Ef32single-precision float4number, rounded to the nearest f32
Ff64double-precision float8number

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 \ y0–3 discrete4–7 unsigned8–B signedD–F float
0 smin00–0304–0708–0B0D–0F
1 ssec10–1314–1718–1B1D–1F
2 sms20–2324–2728–2B2D–2F
3 sns30–3334–3738–3B3D–3F
4 cts40–4344–4748–4B4D–4F
5 f1650–5354–5758–5B5D–5F
6 f3260–6364–6768–6B6D–6F
TagDecimaldtypeReads as
3E0115873sns_f32server time in ns, a 32-bit float; id 1
300012288sns_d8server time in ns, a discrete 8-bit key; id 0
4F0120225cts_f64the client's time in ns, a 64-bit float; id 1
540721511f16_u8a 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.

StrategyBehaviourValid for
step-hold (default)The last value at or before x.every Tag; the only one for discrete Tags
linearThe straight line between the point before and the point after x.unsigned, signed, float
splineA 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 x gives its y;
  • nothing stored at or before x gives null;
  • nothing stored after x gives the last value before it, whatever the strategy;
  • spline with only one point on each side is linear;
  • estimates of numeric y are floats, so JSON numbers, even when a point is stored exactly at x (a u64 stored as "18446744073709551615" estimates as 1.8446744073709552e19: use raw for 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.

JSON
→ [{ "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:

TypeScript
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
OpFlagDoes
pubPStore a measurement, then deliver it.
subSReceive the measurements of one address.
sub-groupSReceive 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 /bytes connection takes and sends binary frames only, a /json connection text frames only. Pushed meas messages 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 with 1003.
  • 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 bytes publisher reaches a json subscriber, and the other way round, without losing anything.
  • Errors repeat what you sent. An :error reply echoes the op exactly as it arrived: in bytes its original bytes (an f32 NaN sent as 00 00 C0 7F comes back as 00 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 forbidden in both.

The details are on Metric Broker WebSocket.

Query the history#

REST answers three kinds of questions about any set of Groups and Tags:

QueryAnswers
rawThe stored points in a range, oldest or newest first, up to 10 000 per series.
resampleA given number of evenly spaced estimates over a range: a chart's points.
atOne estimate per series at one x: "what was the value at 03:12?"
Shell
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 REST 201) 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 raw from the last x you 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 same x replaces it, so retrying a cts, f16 or f32 publish 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).

SettingValue
A Group's retention_days1 or more, or null: follow the Domain's defaults
Your Domain's defaultsretention_days 1 or more (required) and an optional max_points
The system default365 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 (needs W).

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.