/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Telemetry Service

Implementation reference for backend/telemetry. For its architectural role, see Telemetry Service Architecture; for the domain model, see Telemetry Ingestion.

A Rust service on Axum, backed by Postgres + TimescaleDB. It ingests readings from constrained hardware, persists them, publishes them for live distribution, manages per-building and per-room thresholds, and forwards commands to physical devices. Its design answers two pressures: adding a metric must not touch the core, and no I/O technology may reach the logic that decides what a reading means.


Source Layout

PathHolds
src/types/Pure types and traits: SensorPlugin, MetricDescriptor, ActionSpec, Reading, Command, threshold resolution, query/bucketing rules. Imports nothing from the rest of the crate.
src/kernel/Use cases — ingest, readings, thresholds, sensors, actions, registration — plus ports.rs, registry.rs, authz.rs. Depends only on contracts and its own port traits.
src/plugins/One file per metric: temperature.rs, people_count.rs, air_quality.rs, plus common.rs. No plugin imports another.
src/adapters/driven/postgres/, redis_fanout.rs, kafka_producer.rs, twin_directory.rs, dispatch.rs.
src/adapters/driving/http_api/ (claims, controllers, error mapping), kafka_consumer.rs.
src/state.rs, src/main.rsShared handler state; composition root.
migrations/0001_init, 0002_timescale, 0003_rollup. Embedded with sqlx::migrate! and applied at startup.
tests/architecture.rs (fitness), persistence.rs, api.rs, fanout.rs, registration.rs.

Configuration

VariableDefaultPurpose
DATABASE_URL(required)Postgres + TimescaleDB connection string. Migrations run at startup.
REDIS_URLredis://redis:6379Telemetry fan-out and breach alerts.
KAFKA_BROKERSkafka:9092Building-registration topics and the alerts breach topic. Unreachable at boot degrades to a disabled producer rather than failing — readings still land, breaches are counted but not delivered.
DIGITAL_TWIN_URLhttp://digital-twin:3000Building-to-domain lookups for authorization.
PORT3000HTTP listener.
RUST_LOGenv_logger filter.

Adding a Metric

Three steps, none of which touch the kernel.

1. Declare it. A new file in src/plugins/:

static DESCRIPTOR: MetricDescriptor = MetricDescriptor {
    key: "humidity",
    value_field: "humidity",
    label: "Humidity",
    interface_name: "IHumidity",
    unit: Some("%"),
    fields: &[
        FieldSpec { name: "buildingId", kind: FieldKind::NonEmptyString, required: true },
        FieldSpec { name: "roomId",     kind: FieldKind::NonEmptyString, required: true },
        FieldSpec { name: "timestamp",  kind: FieldKind::Finite,         required: true },
        FieldSpec { name: "humidity",   kind: FieldKind::Finite,         required: true },
    ],
};

static BOUNDS: &[BoundSpec] = &[
    BoundSpec { key: "maxHumidity", field: "humidity", label: "Humidity", unit: Some("%"), direction: BoundDirection::Above },
];

A bound names the payload field it compares — not the reading’s value, since one metric can bound several fields — plus the label and unit its alert carries. One alert per breached field.

2. Implement SensorPlugin. validate is check_fields plus reading the value; actions() defaults to &[] and is only overridden for metrics with an actuator.

3. Register it in plugins::all(), which main.rs hands to PluginRegistry::new. A plugin with bounds also joins telemetry_schema::ALERTABLE_METRICS — a test fails until it does — which gives its breaches a delivery path in notification.

GET /contracts, POST /ingest, threshold validation and breach evaluation all pick it up from the declaration. There is no switch statement to extend.

GET /contracts serialises that declaration into ServiceMetricsContract from the shared schemas/telemetry-schema crate — the same types dashboard parses, so the two cannot drift.

The declaration is the single source of truth

In the Node predecessor the served catalog and the per-module validate() were two independent lists. They drifted: airQuality advertised eight required fields and checked two, and the catalog said building while every validator required buildingId. MetricDescriptor.fields now drives both, which makes that class of drift inexpressible.


Request Lifecycle

IngestionPOST /ingest, device-facing so no user JWT and still exempt from the edge gate, but not open: the caller signs the request itself. The route takes a batch, always — a lone device sends one reading in the array, so there is no second route and no second code path.

  1. X-Signature must equal HMAC-SHA256(TELEMETRY_INGEST_SECRET, raw_body) in lowercase hex — missing, malformed or mismatched is 401 before any parsing, persistence or fanout. Bodies over 1 MiB are 413.
  2. Envelope {buildingId, readings[]}, capped at 500 readings. Each reading carries its own type, which selects the plugin and is stripped; the envelope supplies buildingId. A reading naming a different building is rejected — a batch maps to exactly one fan-out channel.
  3. Every reading is validated before anything is written. All-or-nothing: one failure persists nothing, publishes nothing, and one 422 names every offending field, each reading’s prefixed with its index (readings[3]: ...). An unknown metric is one of those errors, not a 404 — within a batch it is a bad element of a payload, not an unroutable request.
  4. One bulk insert, with one threshold lookup for the whole tick running alongside it.
  5. Thresholds resolve room-first, then building; each breached field publishes one AlertEvent, carrying the building and room names from registration, to the alerts topic, keyed buildingId:roomId — but only once the write has committed.
  6. One telemetry:raw message for the whole tick.
  7. 202 {accepted, readings}.

The signature covers the exact bytes on the wire, so a re-serialised body fails: a signing client must send the same string it hashed.

Why a batch is one message, not N

A tick is a single observation of one building at one instant. Sent as N messages that fact is lost at the first hop and cannot be recovered downstream, so per-message overhead multiplies across every service. The fan-out envelope is {buildingId, ingestedAt, readings[]}dashboard keys the channel on buildingId and socket relays it opaquely.

The envelope carries no shape tag. Every message is a tick, so a constant type: "batch" would carry no information, and type already names the metric on each reading — one key with two meanings at two levels is the kind of thing a reader gets wrong once and then trusts.

There is deliberately no /ingest/batch alongside a single-reading /ingest. Two routes meant two kernel entry points, two store methods and two fan-out methods to keep in step — and a sub-path the edge does not ungate, since Caddyfile and the Istio policy exempt the exact path /telemetry/ingest. One route with N ≥ 1 removes both problems.

One shared secret, not one per building

Every gateway currently signs with the same key, so a legitimate gateway could still post for a building that is not its own. That is bounded by there being no third-party gateways yet. Per-building keys are a store lookup behind the same header, with no wire change. Replay is likewise undefended — a captured body stays valid — which matches the mesh’s documented “hard perimeter, guarded interior” posture, where an in-mesh attacker could forge x-gateway-claims regardless.

A threshold-evaluation failure is logged and does not fail the ingest; a persistence failure does, and is counted by telemetry_ingest_persist_failures_total.

ReadsGET /{sensorType}/latest, /entireBuilding, /dashboard. Each resolves the building’s domains through digital-twin and evaluates Cedar before touching data. latest on an empty room is 404.

A read is served in the same flat shape as the telemetry socket event: envelope plus every metric field at the top level under its own name. The trimmed payload column is a storage detail — it is re-inflated on the way out and never appears in a response. A client that reads a REST snapshot and then live events must not have to handle two shapes; when it did, a dashboard column stayed blank until the first socket tick overwrote the row.

ActionsPOST /executeAction. The kernel validates the command against the plugin’s ActionSpec; the dispatch adapter translates it into the device’s own vocabulary. See Sensor Actions & the Device ACL.


Data Model

Five tables. Full retention and compression design in Telemetry Storage & Retention.

TableHolds
readingsHypertable. building_id, room_id, metric, ts, value, payload. Compressed after 7 days, dropped after 14 — readings_hourly keeps the summaries.
thresholdsbuilding_id, room_id, metric, bounds jsonb. room_id NULL = building-level; room-level wins.
sensorsbuilding_id, room_id, sensor_id, sensor_type, driver, endpoint.
buildings / building_roomsRegistration output. building_rooms is what lets the threshold clone view report rooms that have no bounds.

Testing

just test telemetry runs the unit and fitness suites — the whole kernel proven against in-memory fakes, with no database. just test telemetry-integration stands up TimescaleDB, Redis and Kafka in a throwaway compose project and runs tests/*.rs against them, each test on its own freshly created database.

tests/architecture.rs is a gate, not a formality: it fails if the kernel gains an I/O import, if a plugin imports a sibling, or if contracts grows a dependency.