Skip to content

Commit c33cbbb

Browse files
committed
feat: anomaly detection and Prometheus metrics endpoint
1 parent 78d3be0 commit c33cbbb

6 files changed

Lines changed: 181 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
55

66
## [Unreleased]
77
### Added
8+
- Prometheus `/metrics` endpoint (ingest rate, active alerts, anomalies, fleet
9+
KPIs) so rosscope is itself scrapeable and Grafana-graphable. Unit-tested.
10+
- SVG dashboard preview (`docs/preview.svg`) as the README hero until a screen
11+
recording is added.
812
- Multivariate anomaly detection: a rolling Mahalanobis-distance detector
913
(`alerts/anomaly.py`) flags unusual combinations of signals that fixed
1014
thresholds miss; configurable under `anomaly:` in `rules.yaml`. Unit-tested.

README.md

Lines changed: 80 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
1-
# 📡 rosscope
1+
<p align="center">
2+
<img src="https://img.shields.io/github/actions/workflow/status/ATemova/ros-scope/ci.yml?branch=main&style=flat-square&logo=githubactions&logoColor=white&label=CI" />
3+
<img src="https://img.shields.io/badge/Python-3.12-blue?style=flat-square&logo=python&logoColor=white" />
4+
<img src="https://img.shields.io/badge/ROS%202-Humble-22314E?style=flat-square&logo=ros&logoColor=white" />
5+
<img src="https://img.shields.io/badge/Backend-FastAPI-009688?style=flat-square&logo=fastapi&logoColor=white" />
6+
<img src="https://img.shields.io/badge/Storage-TimescaleDB-FDB515?style=flat-square" />
7+
<img src="https://img.shields.io/badge/Streaming-Redis%20Streams-DC382D?style=flat-square&logo=redis&logoColor=white" />
8+
<img src="https://img.shields.io/badge/Frontend-Three.js%20%7C%20uPlot-049EF4?style=flat-square&logo=threedotjs&logoColor=white" />
9+
<img src="https://img.shields.io/badge/Live-WebSockets-FF8A3D?style=flat-square" />
10+
<img src="https://img.shields.io/badge/Metrics-Prometheus-E6522C?style=flat-square&logo=prometheus&logoColor=white" />
11+
<img src="https://img.shields.io/badge/Alerts-Threshold%20%2B%20Anomaly-E4574C?style=flat-square" />
12+
<img src="https://img.shields.io/badge/Infra-Docker%20Compose-2496ED?style=flat-square&logo=docker&logoColor=white" />
13+
<img src="https://img.shields.io/badge/License-MIT-3DA639?style=flat-square" />
14+
</p>
15+
16+
# 📡 Ros Scope
217

318
**Live telemetry, health, and 3D pose for ROS 2 robot fleets — runnable with one command, no robot required.**
419

5-
---
20+
Ros Scope is a production-style observability platform for robot fleets. It bridges ROS 2 telemetry into a scalable time-series infrastructure and serves a live dashboard with 3D pose visualization, signal charts, per-topic health, session replay, and threshold, staleness, and anomaly alerting. The whole stack comes up with `docker compose up` and streams a synthetic fleet immediately — so you can try it without ROS installed and without hardware — then runs unchanged against a real robot via the ROS 2 bridge.
621

7-
![Python](https://img.shields.io/badge/Python-3.12-3776AB?logo=python&logoColor=white)
8-
![ROS 2](https://img.shields.io/badge/ROS%202-Humble-22314E?logo=ros&logoColor=white)
9-
![Backend](https://img.shields.io/badge/Backend-FastAPI-009688?logo=fastapi&logoColor=white)
10-
![Storage](https://img.shields.io/badge/Storage-TimescaleDB-FDB515?logo=timescale&logoColor=white)
11-
![Streaming](https://img.shields.io/badge/Streaming-Redis%20Streams-DC382D?logo=redis&logoColor=white)
12-
![Frontend](https://img.shields.io/badge/Frontend-Three.js%20%7C%20uPlot-049EF4?logo=threedotjs&logoColor=white)
13-
![Infra](https://img.shields.io/badge/Infra-Docker%20Compose-2496ED?logo=docker&logoColor=white)
14-
![Live](https://img.shields.io/badge/Live-WebSocket%20Stream-FF8A3D)
15-
![Alerts](https://img.shields.io/badge/Alerts-Threshold%20%2B%20Staleness-E4574C)
16-
![Demo](https://img.shields.io/badge/Demo-No%20Hardware%20Needed-5FB98E)
17-
[![CI](https://img.shields.io/github/actions/workflow/status/ATemova/ros-scope/ci.yml?branch=main&logo=githubactions&logoColor=white&label=CI)](https://github.com/ATemova/ros-scope/actions/workflows/ci.yml)
18-
![License](https://img.shields.io/badge/License-MIT-3DA639)
22+
## 🎥 Dashboard
1923

20-
Ros Scope is a production-style observability platform for robot fleets. It bridges ROS 2 topics into a time-series database and serves a live dashboard with 3D pose, signal charts, per-topic health, and threshold + staleness alerts. The whole stack comes up with `docker compose up` and streams a synthetic fleet immediately — so you can try it without ROS installed and without hardware — then runs unchanged against a real robot via the ROS 2 bridge.
24+
![Ros Scope dashboard](docs/dashboard.gif)
2125

26+
*Live fleet monitoring: robot trajectories, telemetry streams, topic health, and real-time alerts.*
2227

23-
## What it does
28+
## 🧠 Motivation
2429

25-
- **Live 3D pose** for the whole fleet with per-robot trajectory trails, fed by odometry over a WebSocket.
26-
- **Streaming signal charts** (battery, CPU temperature, IMU) with history backed by TimescaleDB.
27-
- **Topic health strip** showing the observed rate of each topic and flagging the moment one goes stale.
28-
- **Alerting** on thresholds (battery low/critical, CPU overheat), on missing data (a sensor topic that stops arriving), and on **multivariate anomalies** — a rolling Mahalanobis-distance detector that flags unusual *combinations* of signals the fixed thresholds miss — all pushed live to the dashboard.
29-
- **Session record & replay**: bookmark a time range, then scrub through it on a timeline (play/pause/seek/speed) with the whole dashboard — 3D trails, charts, alerts — replaying from stored data.
30+
Modern robotic systems generate large volumes of telemetry across distributed sensors, actuators, and diagnostic channels. ROS 2 provides robust communication, but not a unified observability solution comparable to those used in cloud-native systems. Ros Scope closes that gap by applying observability principles from distributed systems to robotics: real-time fleet monitoring, historical telemetry storage, topic-health analysis, event-driven alerting, session record/replay, and hardware-independent reproducibility.
3031

31-
## Architecture
32+
## 🏗 Architecture
3233

3334
```mermaid
3435
flowchart LR
@@ -66,35 +67,58 @@ flowchart LR
6667

6768
The design decision worth calling out: **ingestion is separated from serving.** A Redis Stream absorbs sensor-rate bursts, a dedicated worker drains it with batched inserts, and the API only reads — so write throughput and the web tier scale independently. Full rationale in [`docs/architecture.md`](docs/architecture.md).
6869

69-
## Tech stack
70+
## 🚀 Core Features
7071

71-
| Layer | Tools |
72-
|------------|-------|
73-
| Backend | FastAPI, Uvicorn, asyncpg |
74-
| Storage | TimescaleDB (hypertables, continuous aggregates, retention) |
75-
| Streaming | Redis Streams (pipeline) + Redis Pub/Sub (alerts) |
76-
| Robotics | ROS 2 Humble, rclpy, standard `sensor_msgs` / `nav_msgs` |
77-
| Frontend | Three.js (3D pose), µPlot (charts), vanilla ES — no build step |
78-
| Infra | Docker Compose, multi-service, health-gated startup |
72+
- **Fleet monitoring** — real-time status across multiple robots, with online/offline detection and fleet-wide KPIs.
73+
- **3D pose visualization** — live robot positions with historical trajectory trails in a shared scene.
74+
- **Telemetry analytics** — battery, CPU temperature, and IMU signals with history backed by TimescaleDB and 1-second rollups.
75+
- **Alert engine** — threshold rules, topic staleness/missing-data detection, and **multivariate anomaly detection** (rolling Mahalanobis distance) that flags unusual *combinations* of signals the thresholds miss.
76+
- **Session record & replay** — bookmark a time range, then scrub through it on a timeline (play/pause/seek/speed) with the whole dashboard replaying from stored data.
77+
- **Self-observable** — a Prometheus `/metrics` endpoint so Ros Scope can be scraped and graphed in Grafana like any production service.
7978

80-
## Quickstart
79+
## 🔌 API
80+
81+
| Method | Path | Purpose |
82+
|--------|------|---------|
83+
| GET | `/api/summary` | Fleet KPIs: robots online, active alerts, lowest battery |
84+
| GET | `/api/robots` | Known robots with first/last-seen timestamps |
85+
| GET | `/api/topics?robot_id=` | Topics & metrics seen for a robot |
86+
| GET | `/api/series?robot_id=&metric=&minutes=` | Metric history (raw, or 1s rollup for long windows) |
87+
| GET | `/api/poses?robot_id=&seconds=` | Recent pose samples |
88+
| GET | `/api/alerts?limit=` | Most recent alerts |
89+
| GET | `/api/health` | Per-topic observed rate and last-seen |
90+
| POST | `/api/sessions/start` | Begin recording (bookmarks a time range) |
91+
| POST | `/api/sessions/{id}/stop` | End a recording |
92+
| GET | `/api/sessions` | List recorded sessions |
93+
| GET | `/api/sessions/{id}/data` | Replay payload (pose trails, series, alerts) |
94+
| WS | `/ws/live` | Live telemetry (stream tail) + alerts (pub/sub) |
95+
| GET | `/metrics` | Prometheus metrics — scrape with Prometheus, graph in Grafana |
96+
97+
## 📈 Tech Stack
98+
99+
| Layer | Technologies |
100+
|-------|--------------|
101+
| Robotics | ROS 2 Humble, rclpy, standard `sensor_msgs` / `nav_msgs` |
102+
| Backend | FastAPI, Uvicorn, asyncpg |
103+
| Storage | TimescaleDB (hypertables, continuous aggregates, retention) |
104+
| Streaming | Redis Streams (pipeline) + Redis Pub/Sub (alerts) |
105+
| Frontend | Three.js (3D pose), µPlot (charts), vanilla ES — no build step |
106+
| Observability | Prometheus `/metrics` |
107+
| Infrastructure | Docker Compose, multi-service, health-gated startup |
108+
| Testing | Pytest, Ruff, GitHub Actions CI |
109+
110+
## ▶️ Quick Start
81111

82112
No robot and no ROS install required — the default stack runs a synthetic fleet.
83113

84114
```bash
85115
git clone https://github.com/ATemova/ros-scope.git
86-
cd rosscope
116+
cd ros-scope
87117
docker compose up --build
88118
```
89119

90120
Open **http://localhost:8000**. Within a few seconds you'll see three robots streaming, trails drawing in 3D, and the first alerts arriving as the simulated batteries drain and one robot's `/scan` topic drops out.
91121

92-
Run the unit tests for the alert engine (no containers needed):
93-
94-
```bash
95-
pip install pytest pyyaml && python -m pytest -q tests
96-
```
97-
98122
### Feeding real ROS 2 data
99123

100124
The `ros` profile starts the rclpy bridge plus a small demo publisher so you can verify the ROS path end to end:
@@ -105,61 +129,48 @@ docker compose --profile ros up --build
105129

106130
The bridge subscribes to `/battery_state`, `/imu`, `/odom`, and `/diagnostics` and forwards them into the same pipeline. Point it at your own robot or a Gazebo bringup by replacing the demo publisher.
107131

108-
## API
109-
110-
| Method | Path | Purpose |
111-
|--------|------|---------|
112-
| GET | `/api/summary` | Fleet KPIs: robots online, active alerts, lowest battery |
113-
| GET | `/api/robots` | Known robots with first/last-seen timestamps |
114-
| GET | `/api/topics?robot_id=` | Topics & metrics seen for a robot |
115-
| GET | `/api/series?robot_id=&metric=&minutes=` | Metric history (raw, or 1s rollup for long windows) |
116-
| GET | `/api/poses?robot_id=&seconds=` | Recent pose samples |
117-
| GET | `/api/alerts?limit=` | Most recent alerts |
118-
| GET | `/api/health` | Per-topic observed rate and last-seen |
119-
| POST | `/api/sessions/start` | Begin recording (bookmarks a time range) |
120-
| POST | `/api/sessions/{id}/stop` | End a recording |
121-
| GET | `/api/sessions` | List recorded sessions |
122-
| GET | `/api/sessions/{id}/data` | Replay payload (pose trails, series, alerts) |
123-
| WS | `/ws/live` | Live telemetry (stream tail) + alerts (pub/sub) |
124-
125-
## Development & quality
132+
## 🧪 Development & Quality
126133

127-
Lint and the full test suite run with no containers — the rule engine, schema, and
128-
simulator logic are pure and infra-free, which is what keeps CI fast:
134+
Lint and the full test suite run with no containers — the rule engine, schema, simulator, anomaly detector, and metrics formatter are pure and infra-free, which keeps CI fast:
129135

130136
```bash
131137
pip install -r requirements-dev.txt
132138
ruff check .
133-
pytest -q # 13 tests
139+
pytest -q # 22 tests
134140
```
135141

136-
CI runs both as separate jobs on every push. See [`CONTRIBUTING.md`](CONTRIBUTING.md)
137-
and [`CHANGELOG.md`](CHANGELOG.md).
142+
CI runs lint and tests as separate jobs on every push. See [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`CHANGELOG.md`](CHANGELOG.md).
138143

139-
## Engineering decisions
144+
## 🛠 Engineering Decisions
140145

141146
A few choices that make this more than a toy, and what they buy:
142147

143148
- **Stream buffer, not direct DB writes.** Redis Streams decouple producers from storage and survive a worker restart via consumer groups, so no samples are lost during a redeploy.
144-
- **Batched `COPY` ingestion.** The ingest worker accumulates samples and writes them with `copy_records_to_table`, which is dramatically cheaper than row-by-row inserts at sensor rates.
149+
- **Batched `COPY` ingestion.** The ingest worker accumulates samples and writes them with `copy_records_to_table`, dramatically cheaper than row-by-row inserts at sensor rates.
145150
- **Continuous aggregate for history.** Charts over long windows read a 1-second rollup instead of raw rows, keeping payloads small and queries fast; raw data has a 7-day retention policy.
146-
- **Staleness as a first-class signal.** "No data" is often the most important alert in robotics. The engine tracks last-seen time per topic and fires when a stream goes quiet — not just on bad values.
147-
- **Interchangeable producers.** A shared envelope means the synthetic publisher and the ROS 2 bridge are drop-in replacements, which is what lets the project demo with zero hardware.
151+
- **Staleness as a first-class signal.** "No data" is often the most important alert in robotics — the engine tracks last-seen time per topic and fires when a stream goes quiet, not just on bad values.
152+
- **Anomalies beyond thresholds.** A rolling Mahalanobis-distance detector catches unusual multivariate patterns (e.g. a CPU-temperature blip that never crosses the hard limit).
153+
- **Interchangeable producers.** A shared envelope means the synthetic publisher and the ROS 2 bridge are drop-in replacements — which is what lets the project demo with zero hardware.
154+
- **Self-observable.** A Prometheus `/metrics` endpoint exposes ingest rate, active alerts, anomalies, and fleet KPIs, so the observability platform is itself observable.
148155

149-
## Project layout
156+
## 📁 Project Layout
150157

151158
```
152159
common/ shared telemetry envelope + logging helper (used by every service)
153160
sim/ synthetic fleet publisher (default data source)
154161
bridge/ ROS 2 rclpy bridge + demo bot (profile: ros)
155162
ingest/ Redis stream -> TimescaleDB worker
156163
alerts/ threshold, staleness + anomaly rule engine
157-
api/ FastAPI: REST, /ws/live, static dashboard
164+
api/ FastAPI: REST, /ws/live, /metrics, static dashboard
158165
api/static/ the dashboard (Three.js + µPlot)
159166
db/ TimescaleDB schema + continuous aggregate
160-
tests/ unit tests: rules, schema, simulator, anomaly
167+
tests/ unit tests: rules, schema, simulator, anomaly, metrics
161168
```
162169

170+
## 🎯 Outcome
171+
172+
Ros Scope demonstrates how observability principles from modern distributed systems apply to robotic fleets: a reproducible environment for monitoring, analyzing, and diagnosing robot behavior, compatible with both simulated and real-world deployments. It serves as both a portfolio reference architecture and a practical starting point for telemetry-driven robotic observability.
173+
163174
## License
164175

165176
MIT — see [LICENSE](LICENSE).

api/main.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
import asyncpg
2020
import redis.asyncio as aioredis
2121
from fastapi import FastAPI, Query, WebSocket, WebSocketDisconnect
22-
from fastapi.responses import JSONResponse
22+
from fastapi.responses import JSONResponse, PlainTextResponse
2323
from fastapi.staticfiles import StaticFiles
2424

25+
from api.metrics import render_prometheus
2526
from common.log import get_logger
2627
from common.schema import ALERTS_CHANNEL, STREAM, Sample
2728

@@ -267,11 +268,38 @@ async def ws_live(ws: WebSocket):
267268
await pubsub.aclose()
268269

269270

271+
@app.get("/metrics")
272+
async def metrics():
273+
"""Prometheus exposition of rosscope's own state — scrape this and graph it
274+
in Grafana. The observability platform is itself observable."""
275+
pool = state["pool"]
276+
online = await pool.fetchval(
277+
"SELECT count(DISTINCT robot_id) FROM telemetry WHERE time > now() - interval '10 seconds'")
278+
samples_1m = await pool.fetchval(
279+
"SELECT count(*) FROM telemetry WHERE time > now() - interval '1 minute'")
280+
active_alerts = await pool.fetchval(
281+
"SELECT count(*) FROM alerts WHERE time > now() - interval '5 minutes'")
282+
anomaly_1h = await pool.fetchval(
283+
"SELECT count(*) FROM alerts WHERE rule='anomaly' AND time > now() - interval '1 hour'")
284+
min_batt = await pool.fetchval(
285+
"SELECT min(value) FROM telemetry WHERE metric='voltage' AND time > now() - interval '30 seconds'")
286+
sessions = await pool.fetchval("SELECT count(*) FROM sessions")
287+
body = render_prometheus([
288+
("rosscope_robots_online", "gauge", "Robots reporting telemetry in the last 10s", online or 0),
289+
("rosscope_samples_1m", "gauge", "Telemetry samples ingested in the last minute", samples_1m or 0),
290+
("rosscope_active_alerts", "gauge", "Alerts raised in the last 5 minutes", active_alerts or 0),
291+
("rosscope_anomaly_alerts_1h", "gauge", "Anomaly alerts in the last hour", anomaly_1h or 0),
292+
("rosscope_min_battery_volts", "gauge", "Lowest battery voltage in the last 30s", min_batt),
293+
("rosscope_sessions_total", "gauge", "Recorded sessions", sessions or 0),
294+
])
295+
return PlainTextResponse(body, media_type="text/plain; version=0.0.4; charset=utf-8")
296+
297+
270298
@app.get("/healthz")
271299
async def healthz():
272300
return JSONResponse({"ok": True})
273301

274302

275303
# Static dashboard (mounted last so /api and /ws win).
276304
app.mount("/", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static"), html=True),
277-
name="static")
305+
name="static")

api/metrics.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Prometheus text-format exposition for the /metrics endpoint.
2+
3+
rosscope is an observability platform, so it exposes its own metrics for
4+
Prometheus to scrape and Grafana to graph. The formatter is a pure function
5+
(no FastAPI, no database), so it is unit-tested directly in
6+
tests/test_metrics.py.
7+
"""
8+
from __future__ import annotations
9+
10+
11+
def render_prometheus(metrics) -> str:
12+
"""Render Prometheus text exposition format.
13+
14+
`metrics` is an iterable of (name, type, help, value) tuples. A value of
15+
None is emitted as NaN (a valid gauge value meaning "no data").
16+
"""
17+
lines: list[str] = []
18+
for name, mtype, help_text, value in metrics:
19+
lines.append(f"# HELP {name} {help_text}")
20+
lines.append(f"# TYPE {name} {mtype}")
21+
lines.append(f"{name} {_fmt(value)}")
22+
return "\n".join(lines) + "\n"
23+
24+
25+
def _fmt(value) -> str:
26+
if value is None:
27+
return "NaN"
28+
f = float(value)
29+
return str(int(f)) if f.is_integer() else repr(f)

docs/dashboard.gif

8.63 MB
Loading

tests/test_metrics.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Tests for the Prometheus exposition formatter (api/metrics.py).
2+
3+
Pure logic, no FastAPI or database needed.
4+
"""
5+
import os
6+
import sys
7+
8+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
9+
10+
from api.metrics import render_prometheus
11+
12+
13+
def test_emits_help_type_and_value():
14+
out = render_prometheus([("rosscope_robots_online", "gauge", "Robots online", 3)])
15+
assert "# HELP rosscope_robots_online Robots online" in out
16+
assert "# TYPE rosscope_robots_online gauge" in out
17+
assert "rosscope_robots_online 3" in out
18+
assert out.endswith("\n")
19+
20+
21+
def test_none_becomes_nan():
22+
out = render_prometheus([("x", "gauge", "h", None)])
23+
assert "x NaN" in out
24+
25+
26+
def test_integer_valued_float_has_no_decimal():
27+
out = render_prometheus([("a", "gauge", "h", 4.0), ("b", "gauge", "h", 21.5)])
28+
assert "\na 4\n" in out
29+
assert "b 21.5" in out
30+
31+
32+
def test_multiple_metrics_each_have_a_header_block():
33+
out = render_prometheus([
34+
("m1", "gauge", "first", 1),
35+
("m2", "gauge", "second", 2),
36+
])
37+
assert out.count("# TYPE") == 2
38+
assert out.count("# HELP") == 2

0 commit comments

Comments
 (0)