CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
Implementation reference for the socket. For its architectural role, see Socket Service Architecture.
A small Rust service built on Axum, socketioxide, and a Redis subscriber. It is the bridge between the broker and the browser: it relays alerts and per-building telemetry to connected clients over WebSockets. It has no database and no domain model. The code follows a functional core / imperative shell split, expressed as the directory layout: src/core/ (rooms.rs, auth.rs, relay.rs) is pure and carries every unit test; src/shell/ (handlers.rs, server.rs, metrics.rs) touches sockets, Redis, and process state; src/main.rs only binds the port. The split is enforced by tests/architecture.rs — see Socket Service Architecture.
Connections are authenticated: a socketioxide connect middleware decodes the mesh-verified claims header before the namespace connection is accepted, so the service knows which account and domains each socket belongs to.
graph TD
MAIN["main.rs\nbind + shutdown signal"] --> SRV["shell/server.rs\nrouter, CORS, Redis loop"]
SRV --> HND["shell/handlers.rs\nconnect middleware, room joins, emits"]
SRV --> REL["core/relay.rs\npure: telemetry_delivery, notification_delivery"]
HND --> AUTH["core/auth.rs\npure: authenticate_claims_header"]
HND --> ROOMS["core/rooms.rs\npure: room_for_building, room_for_domain, building_id_from_channel"]
REL --> ROOMS
SRV --> METRICS["shell/metrics.rs\nregistry + counters"]| Variable | Default | Purpose |
|---|---|---|
REDIS_URL | empty | Broker connection for the subscriber. |
FRONTEND_URL | http://localhost:5173 | Allowed CORS origin for the Socket.IO server (credentials enabled). |
DIGITAL_TWIN_URL | http://digital-twin:3000 | Building → domains lookup backing subscribe_building authorisation. |
SOCKET_MAX_LIFETIME_SECS | 900 | How long a socket may stay open before it is dropped and forced to re-handshake. |
The HTTP/WebSocket server listens on port 3000. A GET /health endpoint backs the Kubernetes probes.
GET /metrics exposes the Prometheus text format. Every series is registered at startup (metrics::init(), called from serve), so a freshly started pod reports each one at 0 rather than omitting it — a counter that only appears after its first increment leaves gaps in rate() and makes “no traffic” indistinguishable from “not scraped”.
| Metric | Type | What it answers |
|---|---|---|
socket_connected_clients | gauge | How many browsers are attached right now. The capacity signal for this tier. |
telemetry_relayed_total | counter | Is the telemetry path alive? Flat while sensors publish means the relay is broken. |
notifications_relayed_total{scope} | counter | Alert volume, split domain (tenant-scoped) vs broadcast (system-wide). A jump in broadcast is worth noticing — those reach every connected client. |
relay_payload_bytes_total{channel} | counter | Volume of data pushed through the relay, split telemetry vs notifications. Measured on the broker payload before fan-out, so it is not egress — multiply by room occupancy for that. |
relay_messages_skipped_total{channel} | counter | Messages dropped because the payload was not valid JSON. Non-zero means a publisher is emitting garbage and readings are silently not reaching browsers. |
socket_connections_rejected_total | counter | Handshakes refused for a missing or malformed claims header. Spikes if the mesh stops injecting x-gateway-claims. |
The genuinely interesting number for a fan-out tier is recipients per message. Getting it requires enumerating a room’s sockets on every relayed message, which allocates on the hot path. relay_payload_bytes_total × room occupancy is the cheap approximation; a real histogram should wait until there is a reason to pay for it.
A socketioxide connect middleware (on_connect.with(authenticate)) runs once per connection, before any event handler:
x-gateway-claims header from the handshake request. Istio’s RequestAuthentication verifies the gateway JWT exactly once, at the mesh ingress — the browser authenticates via its authentication_token cookie on the handshake’s initial HTTP request, and Istio extracts the JWT from that cookie before injecting the verified payload as this header.authenticate_claims_header (in auth.rs, pure and unit-tested) base64-decodes the header and extracts an Identity: account_id, account_name, and the domains the account is a member of (from the payload’s memberships). There is no signature check here — socket trusts the header rather than re-verifying a JWT itself, the same trust model as every other service behind the edge (see Identity & Tenancy Architecture.unauthorized; the client sees a connect_error. On success the identity is stored in the socket’s extensions so the connection is server-authoritative — the client cannot claim rooms it has no right to.At startup the service connects a Redis subscriber and binds two channels to two Socket.IO events:
| Redis subscription | Socket.IO emission | Audience |
|---|---|---|
notifications | emit notification to room domain:{name}, or to every socket | The targeted domain’s members; unscoped messages broadcast to all. |
telemetry:filtered:* (pattern) | emit telemetry to room building:{id} | Only clients in that building’s room. |
For the pattern subscription, the building id is recovered from the channel name via telemetry_schema::building_of_filtered_channel — the prefix is written by dashboard and read here, so it is defined once — and used to target the room. Each incoming message is JSON-parsed before emission; a message that fails to parse is logged and skipped, so one bad publish cannot take the relay down.
The subscriber runs in a reconnect loop: if the initial connection fails (Redis not up yet at pod start) or the subscription later drops (Redis restart, connection killed), it logs, waits one second, and reconnects — indefinitely. This is not optional polish. The Node implementation got it for free from node-redis, which auto-reconnects by default; a single-shot connect is a silent black hole, because /health keeps returning 200 while no telemetry reaches any browser.
/health does not reflect subscriber stateGET /health answers “is the HTTP server up”, not “is the relay actually receiving”. A pod whose Redis subscription is down still reports ready. This matches the Node service’s behaviour and is tolerable because the reconnect loop closes the gap in seconds — but a readiness probe that gated on subscriber health would be strictly better, and is the obvious next improvement if this ever bites.
Notifications are domain-scoped: if the payload carries a domainName, only that domain’s room receives it, so members of one tenant never see another’s alerts. A payload without a domainName (a system-wide message) is broadcast to every client. The payload must parse as notification_schema::Notification to be routed at all; anything else is skipped, so a renamed domainName can never turn a scoped alert into a broadcast — see Notification Schema.
Domain rooms are not client-controlled: on connection the service reads the verified identity and joins the socket to a domain:{name} room for each of the account’s memberships. The client only chooses which buildings to follow:
| Client event | Handler effect |
|---|---|
subscribe_building(buildingId) → acknowledged | Resolves the building’s domains from digital-twin (cached 60s, caller’s claims forwarded) and joins room building:{buildingId} only if the account holds one of them. Refused otherwise, and refused if the directory cannot be reached — counted under socket_subscriptions_rejected_total{reason} as forbidden or lookup_failed. Answers {subscribed, buildingId} either way, plus reason when refused. |
unsubscribe_building(buildingId) | Leaves room building:{buildingId} to stop receiving it. |
sequenceDiagram
participant UI as Vue client
participant WS as socket
participant TW as digital-twin
participant R as Redis
UI->>WS: connect (authentication_token cookie)
Note over WS: Istio verifies the JWT, injects x-gateway-claims — WS decodes it, joins domain:{...} rooms
UI->>WS: subscribe_building(B)
WS->>TW: GET /domain/B (cached 60s)
TW-->>WS: ["acme", ...]
Note over WS: shares a domain? → socket joins room building:B
WS-->>UI: ack {subscribed: true, buildingId: B}
R-->>WS: telemetry:filtered:B message
WS->>UI: emit "telemetry" (room building:B only)
R-->>WS: notifications message (domainName=D)
WS->>UI: emit "notification" (room domain:D only)The join is not immediate — it waits on the digital-twin lookup — so subscribe_building is acknowledged. Anything published to building:{buildingId} before the join lands is emitted to a room the caller has not entered, and socket.io drops it silently: no buffer, no replay. A client that wants no gap should wait for the ack, then take its REST snapshot, then apply live events. The Vue client emits without waiting and is covered by its snapshot refetch (stores/sensorData.ts), which backfills anything missed in that window; a client that only listens has no such safety net.
A caller that ignores it behaves exactly as before. Waiting on it is what makes the snapshot-then-stream handoff ordered rather than lucky.
A socket is dropped once it has been open longer than SOCKET_MAX_LIFETIME_SECS, counted as socket_sessions_expired_total. socket.io-client does not retry a server-forced disconnect, so services/socket.ts reconnects explicitly on reason io server disconnect and useSensorData’s connect handler re-emits subscribe_building; the re-handshake re-reads the session cookie without any user interaction. A building room is only ever joined after the caller’s memberships are checked against the building’s owning domains, so a client cannot follow another tenant’s building by guessing its id. Telemetry is room-scoped so each browser receives only the buildings it is displaying; notifications are scoped to the recipient’s domain so tenants stay isolated. Only a system-wide notification with no domainName is broadcast to everyone.
The service runs as a single Socket.IO process. Two changes are required to run multiple replicas:
For where this service sits in the end-to-end real-time path, see Communication & Data Flow.