hyperhive/hive-c0re/src/stats/otel_metrics.rs
atlas 5eca0cc516 feat(hive-c0re): export metrics whose subject is the hive, not an agent
Every hive-labelled series in the store also carries an agent label, so a
hive is only ever visible as the sum of its agents — and a hive whose c0re
has stopped is indistinguishable from one that simply hosts none.

Adds three instruments to the exporter hive-c0re already runs, each a
projection of a value the process computes anyway: process.uptime (the
semconv name — the spec defines it as a double gauge in seconds, which is
exactly this instrument), hyperhive.hive.degraded, and
hyperhive.hive.warnings split by level. None carries an agent attribute;
that absence is what makes them selectable as hive-scoped.

The health pair reads warnings::readiness() rather than deriving its own
verdict, and degraded ships as a series instead of being left for a
dashboard query to compute from warnings{level="crit"} — either would put
the "what counts as unhealthy" rule in a second place that disagrees
silently the first time a degrading condition is added.
2026-08-24 19:51:37 +02:00

603 lines
25 KiB
Rust

//! Per-agent container-resource OTEL export. hive-c0re already samples each
//! agent container's cgroup load for the dashboard
//! ([`super::container_stats`]); this rides those same gauges out to the
//! hive's own collector — the same first hop the agents use, named by the
//! standard `OTEL_EXPORTER_OTLP_ENDPOINT` (see `nix/host-modules/hive-c0re`,
//! which sets it from the same binding it hands agents). No toggle of
//! its own, and no credential: a hive's collector takes unauthenticated OTLP
//! on the bridge, and the only hop that presents anything is the swarm tier's,
//! which is the one that leaves the swarm.
//!
//! Emits the OTEL **semconv `container.*`** metrics with the standard
//! `container.name` attribute (so off-the-shelf OTEL/Grafana container
//! dashboards work), plus the hive-specific `agent` / `hive` / `swarm` labels
//! for our own dashboards. The resource additionally carries semconv
//! `host.arch` and `service.version` (the running hyperhive flake rev), so a
//! sample can be attributed to a machine and a deploy.
//! Uses the OpenTelemetry Rust SDK (same crates as
//! `hive-metric`); the blocking OTLP client is deliberate — the metrics SDK's
//! `PeriodicReader` runs on a background thread with no Tokio reactor.
//!
//! Bridging async→sync: [`container_stats::gather`] is async, but the SDK's
//! observable-instrument callbacks are sync. So an async task refreshes a
//! shared snapshot on an interval, and the (sync) callbacks read it.
//!
//! Also emits instruments whose subject is the hive itself rather than a
//! container in it — see [`register_hive_instruments`]. They carry **no
//! `agent` attribute**, and that absence is the point: it is what makes them
//! selectable as `{hive!="", agent=""}`.
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use opentelemetry::KeyValue;
use opentelemetry::metrics::MeterProvider as _;
use opentelemetry_otlp::{MetricExporter, Protocol, WithExportConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use super::container_stats::{self, ContainerResource};
use super::warnings;
/// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset.
const DEFAULT_INTERVAL: Duration = Duration::from_mins(1);
/// Latest container-stats snapshot: written by the async refresher, read by
/// the sync observable-instrument callbacks.
static SNAPSHOT: OnceLock<Arc<Mutex<Vec<ContainerResource>>>> = OnceLock::new();
/// Keep the provider alive for the process lifetime — the `PeriodicReader`
/// exports only while the provider lives.
static PROVIDER: OnceLock<SdkMeterProvider> = OnceLock::new();
/// When this exporter started, i.e. the origin `hyperhive.hive.uptime` counts
/// from. Set once in [`spawn_exporter`], which runs during hive-c0re startup —
/// so it is the process's age to within the startup sequence, and the metric's
/// doc says exporter-start rather than claiming a precision it does not have.
static STARTED: OnceLock<Instant> = OnceLock::new();
/// Spawn the container-resource OTEL exporter if OTEL is configured
/// (`OTEL_EXPORTER_OTLP_ENDPOINT` non-empty). No-op otherwise. Call once at
/// startup.
pub fn spawn_exporter(hyperhive_flake: &str) {
let Some(endpoint) = endpoint() else {
tracing::debug!("otel container-metrics: no endpoint configured, exporter disabled");
return;
};
let _ = STARTED.set(Instant::now());
let snapshot = SNAPSHOT
.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
.clone();
let interval = interval();
// Async refresher: keep the shared snapshot current for the sync callbacks.
let refresh = snapshot.clone();
tokio::spawn(async move {
loop {
let fresh = container_stats::gather().await;
if let Ok(mut g) = refresh.lock() {
*g = fresh;
}
tokio::time::sleep(interval).await;
}
});
match build_provider(interval, snapshot, hyperhive_flake) {
Ok(provider) => {
let _ = PROVIDER.set(provider);
tracing::info!(%endpoint, ?interval, "otel container-metrics: exporter enabled");
}
Err(e) => {
tracing::warn!(error = ?e, "otel container-metrics: exporter init failed");
}
}
}
fn build_provider(
interval: Duration,
snapshot: Arc<Mutex<Vec<ContainerResource>>>,
hyperhive_flake: &str,
) -> Result<SdkMeterProvider> {
// http/json — the only OTLP transport this crate enables (matching
// hive-metric). The Claude SDK path honours `HYPERHIVE_OTEL_PROTOCOL` for
// its own export; this exporter is always http/json.
//
// The address is deliberately NOT passed here. The SDK reads
// `OTEL_EXPORTER_OTLP_ENDPOINT` itself and appends the signal path
// (`/v1/metrics`); `with_endpoint` is taken **verbatim**, so handing it a
// collector's base address POSTs to `/` and 404s on every export — with
// nothing logged, because OTLP export failures go to an error handler no
// binary here installs. That is not hypothetical: it was this module's
// behaviour for its whole existence, and no sample ever reached the store.
// An endpoint names a BASE throughout hyperhive and the
// layer that knows the signal appends to it (the one exception is the
// VictoriaMetrics exporter, whose far end is not a standard OTLP path).
// Construction is identical to `hive-metric`'s on purpose: two producers,
// one collector, one way to resolve the address.
//
// No auth headers: the destination is this hive's own collector, which
// takes unauthenticated OTLP on the bridge. Adding one here would put the
// upstream credential on a hop that never uses it.
let exporter = MetricExporter::builder()
.with_http()
.with_protocol(Protocol::HttpJson)
.build()
.context("build OTLP metric exporter")?;
// Drive the reader's export at `interval` so `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`
// is the real export cadence (not just the snapshot-refresh cadence). The
// refresher runs at the same interval, gather-first, so the snapshot is
// populated before the first export.
let reader = PeriodicReader::builder(exporter)
.with_interval(interval)
.build();
let provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource(hyperhive_flake))
.build();
register_instruments(&provider, snapshot);
Ok(provider)
}
/// Register the observable instruments. Each callback reads the shared
/// snapshot and reports one data point per agent. Instruments are held by the
/// meter/provider (kept alive in `PROVIDER`).
fn register_instruments(provider: &SdkMeterProvider, snapshot: Arc<Mutex<Vec<ContainerResource>>>) {
let meter = provider.meter("hyperhive.container_stats");
// semconv `container.cpu.time` — cumulative CPU seconds (monotonic counter).
let snap = snapshot.clone();
meter
.f64_observable_counter("container.cpu.time")
.with_unit("s")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
if let Some(usec) = c.cpu_time_usec {
#[allow(clippy::cast_precision_loss)]
obs.observe(usec as f64 / 1_000_000.0, &attrs(c));
}
}
}
})
.build();
// semconv `container.memory.usage` — current usage bytes.
let snap = snapshot.clone();
meter
.u64_observable_gauge("container.memory.usage")
.with_unit("By")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
obs.observe(c.mem_current_bytes, &attrs(c));
}
}
})
.build();
// The cgroup memory ceiling (omit when unlimited). Kept `hyperhive.`
// custom — semconv defines `container.memory.usage` but not a matching
// `.limit` metric, so don't claim a spec name that isn't in the spec.
let snap = snapshot.clone();
meter
.u64_observable_gauge("hyperhive.container.memory.limit")
.with_unit("By")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
if let Some(limit) = c.mem_max_bytes {
obs.observe(limit, &attrs(c));
}
}
}
})
.build();
// Custom (`hyperhive.`) metrics semconv doesn't standardise: memory peak,
// on-disk footprint, and the instantaneous cpu percent the dashboard shows.
let snap = snapshot.clone();
meter
.u64_observable_gauge("hyperhive.container.memory.peak")
.with_unit("By")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
if let Some(peak) = c.mem_peak_bytes {
obs.observe(peak, &attrs(c));
}
}
}
})
.build();
let snap = snapshot.clone();
meter
.u64_observable_gauge("hyperhive.container.storage.usage")
.with_unit("By")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
if let Some(disk) = c.disk_bytes {
obs.observe(disk, &attrs(c));
}
}
}
})
.build();
let snap = snapshot;
meter
.f64_observable_gauge("hyperhive.container.cpu.percent")
.with_unit("%")
.with_callback(move |obs| {
if let Ok(g) = snap.lock() {
for c in g.iter() {
obs.observe(c.cpu_pct, &attrs(c));
}
}
})
.build();
register_hive_instruments(&meter);
}
/// Register the instruments whose subject is **this hive**, not a container in
/// it. Each observes with an empty attribute set (`hive` / `swarm` ride the
/// resource), so no data point here carries `agent` — see the module doc for
/// why that absence is the point.
///
/// Each is a projection of a value the process already computes; none is a new
/// source of truth. In particular the health pair comes from
/// [`warnings::readiness`], which is the one place that decides what counts as
/// unhealthy — deriving `degraded` from `warnings{level="crit"}` in a
/// dashboard query instead would put that rule in two places, and they would
/// disagree silently the first time a second degrading condition is added.
///
/// The two health instruments each read the registry separately, because the
/// multi-instrument batch observer (one callback feeding several instruments)
/// does not exist in this SDK: the API crate has no `register_callback`, and
/// the SDK's is `pub(crate)`, reachable only via these per-instrument
/// builders. Two consecutive reads inside one collection pass can therefore
/// straddle a registry change. Both readings are individually valid and the
/// next export agrees again; the shared-snapshot trick the container
/// instruments use would move that window rather than close it, and exists
/// only because `gather()` is async — which `readiness()` is not.
fn register_hive_instruments(meter: &opentelemetry::metrics::Meter) {
// Seconds since the exporter started. A hive that is up reports this every
// interval whether or not it hosts a single agent, which is what makes
// "no agents" distinguishable from "not reporting"; a restart reads as a
// drop back to ~0.
//
// The semconv name, not a `hyperhive.` one: the spec defines
// `process.uptime` as a double gauge in seconds, which is exactly this
// instrument, and inventing a name for a quantity the spec already names
// is the same mistake as claiming a spec name that does not exist (see
// `host_arch` below). Same split the container family already uses — spec
// names where a spec metric exists, `hyperhive.` for the rest.
meter
.f64_observable_gauge("process.uptime")
.with_unit("s")
.with_callback(|obs| {
if let Some(started) = STARTED.get() {
obs.observe(started.elapsed().as_secs_f64(), HIVE_ATTRS);
}
})
.build();
// The hive's own readiness verdict, 1 when degraded. Emitted as a series
// rather than left for a query to compute — see the note above.
meter
.u64_observable_gauge("hyperhive.hive.degraded")
.with_unit("1")
.with_callback(|obs| {
obs.observe(u64::from(warnings::readiness().is_degraded()), HIVE_ATTRS);
})
.build();
// How many warnings are currently raised, split by level. Every level is
// reported every cycle, zero included: an instrument that goes silent when
// there is nothing to report makes "healthy" and "not reporting" the same
// observation, which is the confusion this whole family exists to remove.
meter
.u64_observable_gauge("hyperhive.hive.warnings")
.with_unit("1")
.with_callback(|obs| {
for (level, n) in warning_counts(&warnings::snapshot()) {
obs.observe(n, &[KeyValue::new("level", level)]);
}
})
.build();
}
/// Attributes for the hive-scoped family: **none**.
///
/// Named rather than written as a bare `&[]` at each call site because the
/// emptiness is the load-bearing part — it is what makes these series
/// selectable as `{hive!="", agent=""}`. A bare empty slice reads like nothing
/// was decided; this one has a test asserting no `agent` key ever appears in
/// it, so "helpfully" labelling the hive family per-agent goes red.
const HIVE_ATTRS: &[KeyValue] = &[];
/// One count per warning level, **including the levels at zero**.
///
/// The zeros are the point. An instrument that reports only the levels
/// currently raised goes silent on a healthy hive, and a silent series is
/// indistinguishable from a hive that has stopped reporting — the exact
/// confusion the `hyperhive.hive.*` family exists to remove.
fn warning_counts(raised: &[crate::host_stats::ServerWarning]) -> Vec<(&'static str, u64)> {
warnings::LEVELS
.into_iter()
.map(|level| {
// Summed rather than `count()`ed to stay cast-free: `count()`
// yields a `usize` and the instrument takes a `u64`.
let n: u64 = raised.iter().filter(|w| w.level == level).map(|_| 1).sum();
(level, n)
})
.collect()
}
/// Per-data-point attributes: the spec `container.name` plus the hive `agent`
/// label. (`hive` / `swarm` are constant across this c0re, so they live on the
/// resource.)
fn attrs(c: &ContainerResource) -> Vec<KeyValue> {
vec![
KeyValue::new("container.name", format!("h-{}", c.name)),
KeyValue::new("agent", c.name.clone()),
]
}
/// Resource: `service.name = hyperhive-c0re`, the host architecture, the
/// running hyperhive revision, plus whatever the operator set in
/// `extraResourceAttributes` (where the hive `hive` / `swarm` labels live, same
/// channel the agent-side export uses).
///
/// These belong on the resource rather than on each data point for the same
/// reason `hive` / `swarm` do: they are constant across one `hive-c0re`. Only
/// facts that vary per container (see [`attrs`]) are per-data-point.
///
/// `service.version` is the **flake revision**, not the crate version — the
/// crate version is a workspace constant that never moves between deploys, so
/// it could not answer "which build produced this sample?". `None` when the
/// flake ref carries no rev (a local path ref), in which case the attribute is
/// omitted rather than reported as a guess.
fn resource(hyperhive_flake: &str) -> Resource {
let mut builder = Resource::builder()
.with_service_name("hyperhive-c0re")
.with_attribute(KeyValue::new("host.arch", host_arch()));
if let Some(rev) = crate::auto_update::current_flake_rev(hyperhive_flake) {
builder = builder.with_attribute(KeyValue::new("service.version", rev));
}
for (k, v) in resource_attributes() {
builder = builder.with_attribute(KeyValue::new(k, v));
}
builder.build()
}
/// The host architecture as OTEL semconv spells it.
///
/// Rust's [`std::env::consts::ARCH`] and semconv `host.arch` disagree on the
/// two architectures this actually runs on: rust says `x86_64` / `aarch64`,
/// semconv says `amd64` / `arm64`. Forwarding rust's spelling would emit a
/// label that off-the-shelf dashboards silently fail to match — the same class
/// of problem as guessing a metric name. Anything else is passed through: a
/// non-standard value is more useful than a wrong standard one.
fn host_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
}
}
/// `OTEL_EXPORTER_OTLP_ENDPOINT`, non-empty. The enable signal — and the very
/// variable the SDK reads to build the exporter's URL, so "configured" and
/// "where it goes" cannot disagree. Read here only to decide whether to start
/// at all; the value is never handed to the builder (see `build_provider`).
fn endpoint() -> Option<String> {
std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Export cadence from `HYPERHIVE_OTEL_METRIC_INTERVAL_MS`, else the default.
fn interval() -> Duration {
std::env::var("HYPERHIVE_OTEL_METRIC_INTERVAL_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|ms| *ms > 0)
.map_or(DEFAULT_INTERVAL, Duration::from_millis)
}
/// Extra resource attributes from `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES`
/// (`key=value,key=value`), same env the agent-side export reads.
fn resource_attributes() -> Vec<(String, String)> {
std::env::var("HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES")
.ok()
.map(|s| parse_kv(&s))
.unwrap_or_default()
}
/// Parse `key=value` pairs separated by commas and/or newlines. The value
/// keeps any `=` after the first (so `Authorization=Bearer x=y` → `Bearer x=y`).
fn parse_kv(s: &str) -> Vec<(String, String)> {
s.split([',', '\n'])
.filter_map(|pair| {
let pair = pair.trim();
if pair.is_empty() {
return None;
}
let (k, v) = pair.split_once('=')?;
let k = k.trim();
if k.is_empty() {
None
} else {
Some((k.to_owned(), v.trim().to_owned()))
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_stats::ServerWarning;
fn warning(level: &'static str) -> ServerWarning {
ServerWarning {
kind: "t",
level,
message: String::new(),
}
}
/// The hive family must not be labelled per-agent: `{hive!="", agent=""}`
/// is the whole selector that distinguishes "a measurement of the hive"
/// from "a measurement of an agent that happens to live in one", and a
/// single `agent` attribute here collapses the two.
///
/// Paired with a control on [`attrs`], because this assertion is an
/// *absence*: without proof that the same check finds an `agent` key when
/// one is genuinely present, an empty result would equally mean the test
/// is looking for the wrong thing.
#[test]
fn hive_attrs_carry_no_agent_but_container_attrs_do() {
let has_agent = |kvs: &[KeyValue]| kvs.iter().any(|kv| kv.key.as_str() == "agent");
assert!(
!has_agent(HIVE_ATTRS),
"a hive-scoped series must not carry an `agent` attribute"
);
// Control: the same predicate, on the per-container attributes, which
// are supposed to carry exactly that key.
let sample = ContainerResource {
name: "atlas".to_owned(),
cpu_pct: 0.0,
cpu_time_usec: None,
mem_current_bytes: 0,
mem_peak_bytes: None,
mem_max_bytes: None,
disk_bytes: None,
};
assert!(
has_agent(&attrs(&sample)),
"control failed: the predicate cannot find an `agent` key that IS \
present, so the assertion above proves nothing"
);
}
/// A healthy hive still reports every level, at zero. Reporting only the
/// levels currently raised would make a healthy hive and a hive that has
/// stopped exporting produce the same thing — no series.
#[test]
fn warning_counts_reports_every_level_even_when_none_are_raised() {
let counts = warning_counts(&[]);
assert_eq!(counts.len(), warnings::LEVELS.len());
for (level, n) in counts {
assert_eq!(n, 0, "{level} should be reported as zero, not omitted");
}
}
#[test]
fn warning_counts_splits_by_level() {
let raised = [
warning(warnings::LEVEL_WARN),
warning(warnings::LEVEL_CRIT),
warning(warnings::LEVEL_WARN),
];
let counts = warning_counts(&raised);
assert_eq!(
counts,
vec![(warnings::LEVEL_WARN, 2), (warnings::LEVEL_CRIT, 1)]
);
}
#[test]
fn parse_kv_keeps_bearer_value() {
assert_eq!(
parse_kv("Authorization=Bearer abc=def"),
vec![("Authorization".to_owned(), "Bearer abc=def".to_owned())]
);
assert_eq!(
parse_kv("a=1,\n b=2 ,=bad,"),
vec![
("a".to_owned(), "1".to_owned()),
("b".to_owned(), "2".to_owned())
]
);
}
/// `host.arch` must be the semconv spelling, not rust's. The two disagree
/// on exactly the architectures hyperhive runs on, and the failure is
/// silent — a dashboard filtering `host.arch="amd64"` simply matches
/// nothing if we emit `x86_64`.
#[test]
fn host_arch_is_semconv_not_rust_spelling() {
assert!(
!matches!(host_arch(), "x86_64" | "aarch64"),
"host_arch() forwarded rust's ARCH spelling ({}) — semconv wants amd64 / arm64",
host_arch()
);
// On the architectures we actually ship, pin the exact expected value
// rather than only asserting the negative above.
#[cfg(target_arch = "x86_64")]
assert_eq!(host_arch(), "amd64");
#[cfg(target_arch = "aarch64")]
assert_eq!(host_arch(), "arm64");
}
/// The exporter's destination must come from the standard OTLP variable,
/// because that is the only spelling the SDK appends the signal path to.
///
/// Asserted rather than left to review because the fix here is an
/// *absence* — no `with_endpoint` call — and an absence is exactly what
/// a later "the endpoint is right there, just pass it" edit restores. If
/// this exporter is ever gated on a variable the SDK does not itself read,
/// it resumes posting to a base URL that 404s in silence.
#[test]
fn endpoint_is_the_standard_otlp_var() {
// Serialised against every other env-mutating test in the crate. Both
// variables below are also read by `meta::tests` in this same test
// binary, so "no other test in this module" would be the wrong
// boundary — the module is not the unit that shares the environment,
// the process is.
let _env = crate::test_env::lock();
// SAFETY: serialised by the guard above; both names are restored (to
// absent) before it drops at the end of this test.
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
std::env::set_var("HYPERHIVE_OTEL_ENDPOINT", "http://hyperhive.invalid:4318");
}
assert_eq!(
endpoint(),
None,
"the hive-wide agent-config variable must NOT enable this exporter — \
the SDK does not read it, so its address would never reach the builder"
);
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", " http://10.42.0.1:4318 ");
}
assert_eq!(
endpoint(),
Some("http://10.42.0.1:4318".to_owned()),
"the standard variable enables the exporter, trimmed"
);
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", " ");
}
assert_eq!(
endpoint(),
None,
"whitespace-only is not a configured endpoint"
);
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
std::env::remove_var("HYPERHIVE_OTEL_ENDPOINT");
}
}
}