Skip to content

Commit f0679d4

Browse files
committed
feat: dependency-aware monitoring.
1 parent 2ceea7a commit f0679d4

21 files changed

Lines changed: 754 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Dependency-aware alerting** (`depends_on`) and **display groups** (`group`)
13+
on monitors. When a monitor goes down, the alert is annotated with topology
14+
context: `"caused by X"` if an upstream it depends on is also down (symptom),
15+
or `"impacts: A, B, C"` if all its upstreams are up (root cause with blast
16+
radius). Every monitor still alerts independently — annotations are additive,
17+
nothing is suppressed. The dependency graph is validated as a DAG at load
18+
(Kahn's algorithm); cycles and unknown references are rejected. On the status
19+
page, monitors are grouped by their `group` field under section headers. The
20+
webhook payload carries structural `cause` and `impacted` fields.
1221
- **Mutual surveillance / dead-man's switch** via a `[health]` section and
1322
`[[peers]]`. A node emits an outbound heartbeat to each peer's `ping_url` only
1423
while it is locally healthy (scheduler ticking *and* database writable), so a

Makefile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Local quality gate — mirrors .github/workflows/ci.yml so you can run the exact
2+
# same checks before pushing. `make gate` must be green for CI to pass.
3+
.PHONY: gate fmt clippy deny test fix
4+
5+
# The full gate: formatting, lints, license/advisory/ban checks, and tests.
6+
gate: fmt clippy deny test
7+
8+
fmt:
9+
cargo fmt --all -- --check
10+
11+
clippy:
12+
cargo clippy --workspace --all-targets --locked -- -D warnings
13+
14+
deny:
15+
cargo deny check
16+
17+
test:
18+
cargo test --workspace --locked
19+
20+
# Auto-fix what can be fixed (formatting + machine-applicable clippy suggestions).
21+
fix:
22+
cargo fmt --all
23+
cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged

config.example.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ default_retention_days = 90
112112
# For tcp: target is "host:port"; up = TCP connect succeeds.
113113
# For push: no target; the job calls POST /api/push/{id} (see below).
114114
# TLS certificate expiry is checked automatically for https:// monitors.
115+
#
116+
# Topology (optional):
117+
# group = "infra" # display group on the status page
118+
# depends_on = ["db", "cache"] # upstream monitors this one depends on
119+
# When a monitor goes down, the alert is annotated:
120+
# - "caused by X" if an upstream it depends on is also down (symptom)
121+
# - "impacts: A, B, C" if all its upstreams are up (root cause, blast radius)
122+
# The dependency graph must be acyclic (validated at load).
115123

116124
[[monitors]]
117125
id = "website"
@@ -120,6 +128,8 @@ kind = "http"
120128
target = "https://example.com"
121129
interval_secs = 60
122130
timeout_secs = 10
131+
group = "app"
132+
depends_on = ["api"]
123133
# expected_status = 200 # optional; default: any 2xx counts as up
124134
# degraded_over_ms = 800 # optional; up but slower than this = degraded (yellow)
125135
# slo_latency_ms = 500 # optional; the 24h p95 is flagged met/breached against this
@@ -136,6 +146,8 @@ kind = "http"
136146
target = "https://api.example.com/health"
137147
interval_secs = 30
138148
timeout_secs = 10
149+
group = "app"
150+
depends_on = ["database"]
139151
# Body assertions (http only):
140152
# keyword = "operational" # body must contain this (keyword_invert = true → must NOT)
141153
# json_query = "$.status" # JSONPath (RFC 9535) evaluated against a JSON body
@@ -149,6 +161,7 @@ kind = "tcp"
149161
target = "db.example.com:5432"
150162
interval_secs = 60
151163
timeout_secs = 5
164+
group = "infra"
152165

153166
# Heartbeat / push monitor: down if no ping arrives within `interval_secs`.
154167
# The job calls: curl -fsS -X POST \

crates/hora-core/src/config.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,16 @@ pub struct Monitor {
557557
/// Override how long this monitor's checks are kept before pruning.
558558
#[serde(default)]
559559
pub retention_days: Option<u16>,
560+
/// Display group for the status page (monitors sharing a group are rendered
561+
/// together under a section header). Ungrouped monitors appear last.
562+
#[serde(default)]
563+
pub group: Option<String>,
564+
/// Upstream monitor ids this one depends on. When set, a down alert is
565+
/// annotated with the probable upstream cause (if any upstream is also
566+
/// down) or with the dependents it impacts (if this is a root cause).
567+
/// Validated as a DAG at load time.
568+
#[serde(default)]
569+
pub depends_on: Option<Vec<String>>,
560570
}
561571

562572
// Manual `Debug` (rather than derived) so credentials never leak: `target` and
@@ -586,6 +596,8 @@ impl std::fmt::Debug for Monitor {
586596
.field("push_token", &self.push_token)
587597
.field("check_cert", &self.check_cert)
588598
.field("retention_days", &self.retention_days)
599+
.field("group", &self.group)
600+
.field("depends_on", &self.depends_on)
589601
.finish()
590602
}
591603
}
@@ -887,6 +899,8 @@ fn validate(config: &Config) -> anyhow::Result<()> {
887899
}
888900
}
889901

902+
crate::topology::validate_dag(&config.monitors)?;
903+
890904
validate_peers(config, &seen, &channel_names)?;
891905
Ok(())
892906
}
@@ -1780,4 +1794,76 @@ mod tests {
17801794
assert!(!dump.contains("sup3rsecret"), "url secret leaked: {dump}");
17811795
assert!(!dump.contains("tok3n"), "ping token leaked: {dump}");
17821796
}
1797+
1798+
#[test]
1799+
fn parses_group_and_depends_on() {
1800+
let config = parse(
1801+
r#"
1802+
[page]
1803+
[server]
1804+
[[monitors]]
1805+
id = "db"
1806+
name = "DB"
1807+
kind = "tcp"
1808+
target = "db:5432"
1809+
interval_secs = 30
1810+
group = "infra"
1811+
[[monitors]]
1812+
id = "api"
1813+
name = "API"
1814+
target = "https://api.example"
1815+
interval_secs = 30
1816+
group = "app"
1817+
depends_on = ["db"]
1818+
"#,
1819+
);
1820+
assert_eq!(config.monitors[0].group.as_deref(), Some("infra"));
1821+
assert_eq!(
1822+
config.monitors[1].depends_on.as_deref(),
1823+
Some(&vec!["db".to_owned()][..])
1824+
);
1825+
validate(&config).expect("valid topology");
1826+
}
1827+
1828+
#[test]
1829+
fn rejects_depends_on_unknown_monitor() {
1830+
let config = parse(
1831+
r#"
1832+
[page]
1833+
[server]
1834+
[[monitors]]
1835+
id = "api"
1836+
name = "API"
1837+
target = "https://api.example"
1838+
interval_secs = 30
1839+
depends_on = ["ghost"]
1840+
"#,
1841+
);
1842+
let error = validate(&config).unwrap_err().to_string();
1843+
assert!(error.contains("unknown monitor"), "got: {error}");
1844+
}
1845+
1846+
#[test]
1847+
fn rejects_dependency_cycle() {
1848+
let config = parse(
1849+
r#"
1850+
[page]
1851+
[server]
1852+
[[monitors]]
1853+
id = "a"
1854+
name = "A"
1855+
target = "https://a.example"
1856+
interval_secs = 30
1857+
depends_on = ["b"]
1858+
[[monitors]]
1859+
id = "b"
1860+
name = "B"
1861+
target = "https://b.example"
1862+
interval_secs = 30
1863+
depends_on = ["a"]
1864+
"#,
1865+
);
1866+
let error = validate(&config).unwrap_err().to_string();
1867+
assert!(error.contains("cycle"), "got: {error}");
1868+
}
17831869
}

crates/hora-core/src/db.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,27 @@ pub async fn cert_all(pool: &SqlitePool) -> sqlx::Result<HashMap<String, i64>> {
451451
Ok(rows.into_iter().collect())
452452
}
453453

454+
/// Current status from the recent checks (newest first): a single failure only
455+
/// counts as `degraded` until `threshold` consecutive failures confirm `down`.
456+
#[must_use]
457+
pub fn derive_status(recent: &[Latest], threshold: i64) -> &'static str {
458+
let Some(latest) = recent.first() else {
459+
return "unknown";
460+
};
461+
match latest.status {
462+
1 => "up",
463+
2 => "degraded",
464+
_ => {
465+
let needed = usize::try_from(threshold).unwrap_or(usize::MAX);
466+
if recent.len() >= needed && recent.iter().all(|check| check.status == 0) {
467+
"down"
468+
} else {
469+
"degraded"
470+
}
471+
}
472+
}
473+
}
474+
454475
/// Background task: periodically prune each monitor's history to its retention,
455476
/// and drop any data left behind by monitors removed from the config. A shutdown
456477
/// signal lets it stop between ticks instead of being aborted.

crates/hora-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod peer;
99
pub mod probe;
1010
pub mod scheduler;
1111
pub mod supervisor;
12+
pub mod topology;
1213

1314
/// Seconds in a day (UTC), shared across the time-bucketing logic.
1415
pub const SECONDS_PER_DAY: i64 = 86_400;

crates/hora-core/src/peer.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,8 @@ pub(crate) fn spawn_watch(
351351
Event::Down {
352352
monitor: &peer.name,
353353
error: outcome.error.as_deref(),
354+
cause: None,
355+
impacted: &[],
354356
},
355357
)
356358
.await;

crates/hora-core/src/probe.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ mod tests {
296296
push_token: None,
297297
check_cert: None,
298298
retention_days: None,
299+
group: None,
300+
depends_on: None,
299301
}
300302
}
301303

crates/hora-core/src/scheduler.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use tracing::{error, info, warn};
1313
use crate::config::{Config, Kind, Monitor};
1414
use crate::notifications::Notifiers;
1515
use crate::probe::Outcome;
16+
use crate::topology;
1617
use crate::{db, probe};
1718

1819
/// The level a monitor was most recently alerted at, so we never re-alert the
@@ -111,12 +112,18 @@ async fn run(
111112
consecutive_degraded = 0;
112113
if consecutive_down >= threshold && alerted != AlertLevel::Down {
113114
error!(monitor = %monitor.id, failures = consecutive_down, "confirmed down");
115+
let snapshot = config.borrow().clone();
116+
let (cause, impacted_names) =
117+
down_context(&snapshot, &pool, &monitor, threshold).await;
118+
let impacted_refs: Vec<&str> = impacted_names.iter().map(String::as_str).collect();
114119
notifier
115120
.load_full()
116121
.dispatch(
117122
Event::Down {
118123
monitor: &monitor.name,
119124
error: outcome.error.as_deref(),
125+
cause: cause.as_deref(),
126+
impacted: &impacted_refs,
120127
},
121128
monitor.notify.as_deref(),
122129
)
@@ -169,6 +176,39 @@ async fn heartbeat_outcome(pool: &SqlitePool, monitor: &Monitor) -> Option<Outco
169176
heartbeat_outcome_for(pool, &monitor.id, monitor.interval_secs).await
170177
}
171178

179+
/// Compute topology annotation for a down alert: the nearest down upstream
180+
/// (`cause`) if any, or the list of impacted dependents (`impacted`) if this
181+
/// monitor is a root cause. Returns `(None, vec![])` when the monitor has no
182+
/// topology configured.
183+
async fn down_context(
184+
config: &Config,
185+
pool: &SqlitePool,
186+
monitor: &Monitor,
187+
threshold: u32,
188+
) -> (Option<String>, Vec<String>) {
189+
let threshold_i64 = i64::from(threshold.max(1));
190+
191+
let upstreams = topology::transitive_upstreams(&config.monitors, &monitor.id);
192+
for up_id in &upstreams {
193+
let Ok(recent) = db::recent_checks(pool, up_id, threshold_i64).await else {
194+
continue;
195+
};
196+
if db::derive_status(&recent, threshold_i64) == "down"
197+
&& let Some(name) = topology::monitor_name(&config.monitors, up_id)
198+
{
199+
return (Some(name.to_owned()), Vec::new());
200+
}
201+
}
202+
203+
let dependents = topology::transitive_dependents(&config.monitors, &monitor.id);
204+
let impacted: Vec<String> = dependents
205+
.iter()
206+
.filter_map(|dep_id| topology::monitor_name(&config.monitors, dep_id).map(String::from))
207+
.collect();
208+
209+
(None, impacted)
210+
}
211+
172212
/// Evaluate a heartbeat from the stored pings for `id` against `interval_secs`:
173213
/// down (and record it) when none arrived within the interval, up when one did.
174214
/// `None` means no heartbeat yet (or a read error), leaving the status unknown -

0 commit comments

Comments
 (0)