Skip to content

Commit f04effb

Browse files
authored
Add TelemetryOps tests and CI (#2)
1 parent f10a08a commit f04effb

13 files changed

Lines changed: 839 additions & 194 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
build-and-test:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 15
16+
17+
steps:
18+
- name: Check out repository
19+
uses: actions/checkout@v6
20+
21+
- name: Install build dependencies
22+
run: |
23+
sudo apt-get update
24+
sudo apt-get install --yes libsqlite3-dev ninja-build
25+
26+
- name: Configure
27+
run: >
28+
cmake -S . -B build -G Ninja
29+
-DCMAKE_BUILD_TYPE=Release
30+
-DBUILD_TESTING=ON
31+
32+
- name: Build
33+
run: cmake --build build --parallel
34+
35+
- name: Test
36+
run: ctest --test-dir build --output-on-failure

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
build/
2+
build-*/
3+
__pycache__/
4+
*.py[cod]
25
data/*.db
36
*.db
47
*.sqlite

‎CMakeLists.txt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ project(leo_telemetry LANGUAGES CXX)
55
set(CMAKE_CXX_STANDARD 20)
66
set(CMAKE_CXX_STANDARD_REQUIRED ON)
77

8+
include(CTest)
89
include(FetchContent)
910

1011
FetchContent_Declare(
@@ -41,4 +42,8 @@ target_link_libraries(common INTERFACE
4142

4243
add_subdirectory(services/ingest)
4344
add_subdirectory(services/aggregator)
44-
add_subdirectory(services/controlplane)
45+
add_subdirectory(services/controlplane)
46+
47+
if(BUILD_TESTING)
48+
add_subdirectory(tests)
49+
endif()

‎README.md‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# TelemetryOps
22

3+
[![CI](https://github.com/derekk024/TelemetryOps/actions/workflows/ci.yml/badge.svg)](https://github.com/derekk024/TelemetryOps/actions/workflows/ci.yml)
4+
35
TelemetryOps is a small C++20 telemetry pipeline for accepting simulated satellite events, storing them in SQLite, calculating rolling health metrics, and evaluating configurable alert thresholds. It is split into three HTTP services so ingestion, aggregation, and alert evaluation can be run and inspected independently.
46

57
## Architecture
@@ -31,7 +33,7 @@ Each service also exposes process-local request counters in the Prometheus text
3133
- A C++20 compiler
3234
- SQLite 3 development headers and library
3335
- Git and network access during the first CMake configuration
34-
- Python 3 to use the optional load generator
36+
- Python 3 to run the integration test or optional load generator
3537
- `curl` to run the examples below
3638

3739
CMake fetches these pinned dependencies during configuration:
@@ -72,6 +74,25 @@ build/services/aggregator/aggregator
7274
build/services/controlplane/controlplane
7375
```
7476

77+
## Test
78+
79+
The automated suite covers both domain rules and the running service pipeline:
80+
81+
- JSON event validation and SQLite event-ID idempotency
82+
- weighted packet-drop aggregation, percentile interpolation, and link-quality averaging
83+
- alert threshold boundaries and aggregator failures
84+
- real ingest and aggregator processes using a temporary SQLite database, including validation errors, duplicate suppression, satellite/window filtering, aggregate responses, and Prometheus counters
85+
86+
Build and run every test through CTest:
87+
88+
```bash
89+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON
90+
cmake --build build --parallel
91+
ctest --test-dir build --output-on-failure
92+
```
93+
94+
GitHub Actions runs the same build and test sequence for pull requests and pushes to `main`.
95+
7596
## Run locally
7697

7798
Start the services in order. The ingest service must create the database and schema before the aggregator opens that database in read-only mode.
@@ -293,31 +314,37 @@ The final line reports responses received by the generator. It is a paced traffi
293314
- No retention policy or database cleanup
294315
- No durable control-plane configuration or alert history
295316
- No alert delivery mechanism beyond the HTTP response and counters
296-
- No automated test suite or continuous-integration workflow
297317
- No container or multi-host deployment configuration
298318
- Aggregation loads all matching window rows into memory before calculating percentiles
299319
- One SQLite connection per data service, without an application-level connection pool
300-
- `common/include/common/telemetry.hpp` is currently a placeholder; the services do not share a domain-model implementation
301320

302321
## Project structure
303322

304323
```text
305324
.
325+
├── .github/workflows/ci.yml
306326
├── CMakeLists.txt
307327
├── common/
308-
│ └── include/common/telemetry.hpp
328+
│ └── include/common/
329+
│ ├── aggregation.hpp
330+
│ ├── alerts.hpp
331+
│ └── telemetry.hpp
309332
├── scripts/
310333
│ └── load_test.py
311-
└── services/
312-
├── ingest/
313-
│ ├── CMakeLists.txt
314-
│ └── main.cpp
315-
├── aggregator/
316-
│ ├── CMakeLists.txt
317-
│ └── main.cpp
318-
└── controlplane/
319-
├── CMakeLists.txt
320-
└── main.cpp
334+
├── services/
335+
│ ├── ingest/
336+
│ │ ├── CMakeLists.txt
337+
│ │ └── main.cpp
338+
│ ├── aggregator/
339+
│ │ ├── CMakeLists.txt
340+
│ │ └── main.cpp
341+
│ └── controlplane/
342+
│ ├── CMakeLists.txt
343+
│ └── main.cpp
344+
└── tests/
345+
├── CMakeLists.txt
346+
├── domain_tests.cpp
347+
└── service_pipeline_test.py
321348
```
322349

323350
## License
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#pragma once
2+
3+
#include <algorithm>
4+
#include <cstddef>
5+
#include <vector>
6+
7+
namespace telemetry_ops {
8+
9+
struct TelemetryRow {
10+
double latency_ms;
11+
int dropped;
12+
int sent;
13+
double link_quality;
14+
};
15+
16+
struct AggregateMetrics {
17+
std::size_t count = 0;
18+
double drop_rate = 0.0;
19+
double latency_p50_ms = 0.0;
20+
double latency_p95_ms = 0.0;
21+
double avg_link_quality = 0.0;
22+
};
23+
24+
inline double percentile(std::vector<double> values, double percentile_value) {
25+
if (values.empty()) return 0.0;
26+
27+
std::sort(values.begin(), values.end());
28+
const double index =
29+
(percentile_value / 100.0) * static_cast<double>(values.size() - 1);
30+
const auto lower_index = static_cast<std::size_t>(index);
31+
const double fraction = index - static_cast<double>(lower_index);
32+
33+
if (lower_index + 1 < values.size()) {
34+
return values[lower_index] * (1.0 - fraction) +
35+
values[lower_index + 1] * fraction;
36+
}
37+
return values[lower_index];
38+
}
39+
40+
inline AggregateMetrics aggregate(const std::vector<TelemetryRow>& rows) {
41+
AggregateMetrics metrics;
42+
metrics.count = rows.size();
43+
if (rows.empty()) return metrics;
44+
45+
long long sum_dropped = 0;
46+
long long sum_sent = 0;
47+
double sum_link_quality = 0.0;
48+
std::vector<double> latencies;
49+
latencies.reserve(rows.size());
50+
51+
for (const auto& row : rows) {
52+
sum_dropped += row.dropped;
53+
sum_sent += row.sent;
54+
sum_link_quality += row.link_quality;
55+
latencies.push_back(row.latency_ms);
56+
}
57+
58+
if (sum_sent > 0) {
59+
metrics.drop_rate =
60+
static_cast<double>(sum_dropped) / static_cast<double>(sum_sent);
61+
}
62+
metrics.latency_p50_ms = percentile(latencies, 50.0);
63+
metrics.latency_p95_ms = percentile(latencies, 95.0);
64+
metrics.avg_link_quality =
65+
sum_link_quality / static_cast<double>(rows.size());
66+
return metrics;
67+
}
68+
69+
} // namespace telemetry_ops

‎common/include/common/alerts.hpp‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#pragma once
2+
3+
#include <nlohmann/json.hpp>
4+
5+
namespace telemetry_ops {
6+
7+
using json = nlohmann::json;
8+
9+
struct Thresholds {
10+
double latency_p95_ms = 200.0;
11+
double drop_rate = 0.05;
12+
double min_link_quality = 0.7;
13+
int window_s = 600;
14+
};
15+
16+
inline json eval_alerts(const json& metrics, const Thresholds& thresholds) {
17+
json alerts = json::array();
18+
19+
if (!metrics.contains("ok") || !metrics["ok"].is_boolean() ||
20+
!metrics["ok"].get<bool>()) {
21+
alerts.push_back({
22+
{"severity", "HIGH"},
23+
{"type", "AGGREGATOR_ERROR"},
24+
{"message", "metrics not ok"},
25+
});
26+
return alerts;
27+
}
28+
29+
if (metrics.value("count", 0) == 0) return alerts;
30+
31+
const double latency_p95_ms = metrics.value("latency_p95_ms", 0.0);
32+
const double drop_rate = metrics.value("drop_rate", 0.0);
33+
const double link_quality = metrics.value("avg_link_quality", 0.0);
34+
35+
if (latency_p95_ms > thresholds.latency_p95_ms) {
36+
alerts.push_back({
37+
{"severity", "MED"},
38+
{"type", "LATENCY_P95"},
39+
{"value", latency_p95_ms},
40+
{"threshold", thresholds.latency_p95_ms},
41+
});
42+
}
43+
if (drop_rate > thresholds.drop_rate) {
44+
alerts.push_back({
45+
{"severity", "HIGH"},
46+
{"type", "DROP_RATE"},
47+
{"value", drop_rate},
48+
{"threshold", thresholds.drop_rate},
49+
});
50+
}
51+
if (link_quality < thresholds.min_link_quality) {
52+
alerts.push_back({
53+
{"severity", "MED"},
54+
{"type", "LINK_QUALITY"},
55+
{"value", link_quality},
56+
{"threshold", thresholds.min_link_quality},
57+
});
58+
}
59+
return alerts;
60+
}
61+
62+
} // namespace telemetry_ops

0 commit comments

Comments
 (0)