CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
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.
| Path | Holds |
|---|---|
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.rs | Shared 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. |
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | (required) | Postgres + TimescaleDB connection string. Migrations run at startup. |
REDIS_URL | redis://redis:6379 | Telemetry fan-out and breach alerts. |
KAFKA_BROKERS | kafka:9092 | Building-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_URL | http://digital-twin:3000 | Building-to-domain lookups for authorization. |
PORT | 3000 | HTTP listener. |
RUST_LOG | — | env_logger filter. |
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.
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.
Ingestion — POST /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.
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.{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.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.AlertEvent, carrying the building and room names from registration, to the alerts topic, keyed buildingId:roomId — but only once the write has committed.telemetry:raw message for the whole tick.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.
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.
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.
Reads — GET /{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.
Actions — POST /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.
Five tables. Full retention and compression design in Telemetry Storage & Retention.
| Table | Holds |
|---|---|
readings | Hypertable. building_id, room_id, metric, ts, value, payload. Compressed after 7 days, dropped after 14 — readings_hourly keeps the summaries. |
thresholds | building_id, room_id, metric, bounds jsonb. room_id NULL = building-level; room-level wins. |
sensors | building_id, room_id, sensor_id, sensor_type, driver, endpoint. |
buildings / building_rooms | Registration output. building_rooms is what lets the threshold clone view report rooms that have no bounds. |
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.