swarm-controller: extract jobq_metrics into its own hive-jobq-metrics crate

This commit is contained in:
damocles 2026-08-19 17:52:12 +02:00 committed by mara
commit f5ff8698f3
7 changed files with 100 additions and 59 deletions

18
Cargo.lock generated
View file

@ -1785,6 +1785,20 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "hive-jobq-metrics"
version = "0.1.0"
dependencies = [
"anyhow",
"hive-jobq",
"hive-jobq-wire",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "hive-jobq-wire" name = "hive-jobq-wire"
version = "0.1.0" version = "0.1.0"
@ -4558,12 +4572,10 @@ dependencies = [
"forgejo-api", "forgejo-api",
"futures-util", "futures-util",
"hive-jobq", "hive-jobq",
"hive-jobq-metrics",
"hive-jobq-wire", "hive-jobq-wire",
"hive-types", "hive-types",
"hmac 0.13.0", "hmac 0.13.0",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"problem_details", "problem_details",
"reqwest", "reqwest",
"serde", "serde",

View file

@ -12,6 +12,7 @@ members = [
"hive-forge-notify", "hive-forge-notify",
"hive-host-sock", "hive-host-sock",
"hive-jobq", "hive-jobq",
"hive-jobq-metrics",
"hive-jobq-wire", "hive-jobq-wire",
"hive-matrix-mcp", "hive-matrix-mcp",
"hive-metric", "hive-metric",
@ -80,6 +81,7 @@ indicatif = "0.18"
hive-sh4re = { path = "hive-sh4re" } hive-sh4re = { path = "hive-sh4re" }
hive-agent-sock = { path = "hive-agent-sock" } hive-agent-sock = { path = "hive-agent-sock" }
hive-jobq = { path = "hive-jobq" } hive-jobq = { path = "hive-jobq" }
hive-jobq-metrics = { path = "hive-jobq-metrics" }
hive-jobq-wire = { path = "hive-jobq-wire" } hive-jobq-wire = { path = "hive-jobq-wire" }
hive-core-agent-sock = { path = "hive-core-agent-sock" } hive-core-agent-sock = { path = "hive-core-agent-sock" }
hive-claude = "0.1" hive-claude = "0.1"

View file

@ -0,0 +1,18 @@
[package]
name = "hive-jobq-metrics"
edition.workspace = true
version.workspace = true
readme = "README.md"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
hive-jobq.workspace = true
hive-jobq-wire.workspace = true
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
opentelemetry-otlp.workspace = true
tokio.workspace = true
tracing.workspace = true

View file

@ -0,0 +1,20 @@
# hive-jobq-metrics
OTEL export of a [`hive-jobq`](../hive-jobq) graph's state rollup, using the
same [`hive-jobq-wire`](../hive-jobq-wire) `state_rollup()` counts a viewer's
`/rollup` endpoint would serve — ridden out to the collector on a timer
instead of only on request.
**Why this is not part of `hive-jobq` or `hive-jobq-wire`.** Both of those
crates are dependency-light on purpose (no `tokio`, no HTTP client) — the
scheduler is logic, the wire crate is presentation, and neither wants to drag
the OTEL SDK, an async runtime, and an OTLP HTTP client into every consumer
that just wants to run a graph or serialize one to JSON. Metrics export is a
third concern with its own weight, so it gets its own crate rather than
bloating either of theirs.
`spawn_exporter` is generic over `hive_jobq::scheduler::Scheduler<N, R>` — any
host's jobq instance can call it, not just one hardcoded caller. It holds no
process-global state: call it once per graph you want exported, and keep the
returned `SdkMeterProvider` alive for as long as export should continue
(dropping it stops the `PeriodicReader`).

View file

@ -1,12 +1,8 @@
//! OTEL export of a job graph's state rollup — the same //! OTEL export of a [`hive_jobq`] graph's state rollup — the same
//! `hive_jobq_wire::state_rollup()` counts `GET /api/jobq/rollup` already //! [`hive_jobq_wire::state_rollup()`] counts a viewer's rollup endpoint
//! serves, ridden out to the collector on a timer instead of only on //! already serves, ridden out to the collector on a timer instead of only
//! request. First consumer of a generic "add jobq metrics" ask: [`spawn_exporter`] //! on request. See the crate README for why this lives in its own crate
//! is generic over any `hive_jobq::scheduler::Scheduler<N, R>`, not tied to //! rather than inside `hive-jobq` or `hive-jobq-wire`.
//! this crate's own `SwarmNodeKind`/`SwarmResourceKind` — swarm-controller's
//! is just the first instance to wire it. No process-global state either
//! (see [`spawn_exporter`]'s doc comment): call it once per graph you want
//! exported, from as many call sites as you like.
//! //!
//! Same OTEL SDK setup as `hive-c0re::stats::otel_metrics` (container //! Same OTEL SDK setup as `hive-c0re::stats::otel_metrics` (container
//! stats) and `hive-metric` (the one-shot CLI): the metrics SDK's //! stats) and `hive-metric` (the one-shot CLI): the metrics SDK's
@ -17,10 +13,10 @@
//! Bridging async→sync: reading `state_rollup()` needs the scheduler's //! Bridging async→sync: reading `state_rollup()` needs the scheduler's
//! `std::sync::Mutex`, which is fine to lock briefly from a sync callback — //! `std::sync::Mutex`, which is fine to lock briefly from a sync callback —
//! but holding it for the whole OTLP export would block every request //! but holding it for the whole OTLP export would block every request
//! handler that also locks it for however long the export takes. So, same //! handler that also locks it for however long the export takes. So an
//! pattern as the container-stats exporter: an async task refreshes a //! async task refreshes a shared snapshot on an interval, and the (sync)
//! shared snapshot on an interval, and the (sync) SDK callbacks only ever //! SDK callbacks only ever read that snapshot, never the scheduler
//! read that snapshot, never the scheduler directly. //! directly.
use std::hash::Hash; use std::hash::Hash;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -36,30 +32,30 @@ use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
/// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset. /// Default export cadence when `HYPERHIVE_OTEL_METRIC_INTERVAL_MS` is unset.
/// Matches `hive-c0re::stats::otel_metrics`'s default — no reason for the /// Matches `hive-c0re::stats::otel_metrics`'s default — no reason for the
/// two exporters to disagree on how fresh "current" means by default. /// exporters across this tree to disagree on how fresh "current" means by
/// default.
const DEFAULT_INTERVAL: Duration = Duration::from_mins(1); const DEFAULT_INTERVAL: Duration = Duration::from_mins(1);
/// Spawn a jobq-rollup OTEL exporter for `jobq` if OTEL is configured /// Spawn a jobq-rollup OTEL exporter for `jobq` if OTEL is configured
/// (`OTEL_EXPORTER_OTLP_ENDPOINT` non-empty) — `None` otherwise, same /// (`OTEL_EXPORTER_OTLP_ENDPOINT` non-empty) — `None` otherwise, same
/// graceful-absence shape every other optional wiring in this daemon uses /// graceful-absence shape optional OTEL wiring uses elsewhere in this tree.
/// (queue, bridge, forge, webhook secret). /// `service_name` becomes the exported resource's `service.name` — the
/// caller's own binary name, since this crate has no opinion on who's
/// calling it.
/// ///
/// **No process-global state.** Earlier drafts of this held the snapshot and /// **No process-global state.** The snapshot is a plain local `Arc`, and
/// the `SdkMeterProvider` behind `static OnceLock`s, which quietly made this /// the provider is returned rather than stashed in a static — **the caller
/// a swarm-controller singleton — a second call (a second graph, a test, a /// owns it and must keep it alive** (bind it to a named variable, not `_`)
/// future host with more than one jobq instance) would have silently reused /// for as long as export should continue; dropping it stops the
/// the first call's state instead of exporting its own. The snapshot is now /// `PeriodicReader`. Call this once per graph you want exported, from as
/// a plain local `Arc`, and the provider is returned rather than stashed — /// many call sites as you like — nothing here assumes there's only one.
/// **the caller owns it and must keep it alive** (bind it to a named
/// variable, not `_`) for as long as export should continue; dropping it
/// stops the `PeriodicReader`.
/// ///
/// Generic over `N`/`R` (the same parameters `Scheduler` itself takes) /// Generic over `N`/`R` (the same parameters [`hive_jobq::scheduler::Scheduler`]
/// rather than this crate's own `SwarmNodeKind`/`SwarmResourceKind` — the /// itself takes) rather than any one host's node/resource vocabulary — the
/// whole point of the ask was a shape any `hive-jobq` host can plug in, not /// whole point of this crate is a shape any `hive-jobq` host can plug in.
/// one hardcoded to this crate's node/resource vocabulary.
pub fn spawn_exporter<N, R>( pub fn spawn_exporter<N, R>(
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<N, R>>>, jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<N, R>>>,
service_name: &str,
) -> Option<SdkMeterProvider> ) -> Option<SdkMeterProvider>
where where
N: Send + 'static, N: Send + 'static,
@ -87,9 +83,9 @@ where
} }
}); });
match build_provider(interval, snapshot) { match build_provider(interval, snapshot, service_name) {
Ok(provider) => { Ok(provider) => {
tracing::info!(%endpoint, ?interval, "otel jobq-metrics: exporter enabled"); tracing::info!(%endpoint, ?interval, %service_name, "otel jobq-metrics: exporter enabled");
Some(provider) Some(provider)
} }
Err(e) => { Err(e) => {
@ -102,6 +98,7 @@ where
fn build_provider( fn build_provider(
interval: Duration, interval: Duration,
snapshot: Arc<Mutex<Vec<StateCount>>>, snapshot: Arc<Mutex<Vec<StateCount>>>,
service_name: &str,
) -> Result<SdkMeterProvider> { ) -> Result<SdkMeterProvider> {
// Same http/json, endpoint-from-env-only construction as // Same http/json, endpoint-from-env-only construction as
// `hive-c0re::stats::otel_metrics` — see that module's doc comment for // `hive-c0re::stats::otel_metrics` — see that module's doc comment for
@ -116,7 +113,7 @@ fn build_provider(
.build(); .build();
let provider = SdkMeterProvider::builder() let provider = SdkMeterProvider::builder()
.with_reader(reader) .with_reader(reader)
.with_resource(resource()) .with_resource(resource(service_name))
.build(); .build();
register_instruments(&provider, snapshot); register_instruments(&provider, snapshot);
Ok(provider) Ok(provider)
@ -164,19 +161,11 @@ fn state_attr(c: &StateCount) -> KeyValue {
KeyValue::new("state", format!("{:?}", c.state)) KeyValue::new("state", format!("{:?}", c.state))
} }
/// Resource: `service.name = swarm-controller` plus whatever the operator /// Resource: `service.name = service_name` plus whatever the operator set
/// set in `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES` (same channel every /// in `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES` (same channel every other
/// other OTEL exporter in this tree reads its `hive`/`swarm` labels from — /// OTEL exporter in this tree reads its `hive`/`swarm` labels from).
/// this daemon has no single "hive" of its own, so nothing is assumed here fn resource(service_name: &str) -> Resource {
/// beyond what the operator supplies). let mut builder = Resource::builder().with_service_name(service_name.to_owned());
///
/// Hardcodes `swarm-controller` even though [`spawn_exporter`] is generic —
/// a future non-swarm-controller caller of this same function would want a
/// different `service.name`, but that's a real parameter to add when a
/// second caller actually exists, not a guess to make now for one that
/// doesn't.
fn resource() -> Resource {
let mut builder = Resource::builder().with_service_name("swarm-controller");
for (k, v) in resource_attributes() { for (k, v) in resource_attributes() {
builder = builder.with_attribute(KeyValue::new(k, v)); builder = builder.with_attribute(KeyValue::new(k, v));
} }
@ -219,8 +208,8 @@ fn resource_attributes() -> Vec<(String, String)> {
/// Parse `key=value` pairs separated by commas and/or newlines. The value /// Parse `key=value` pairs separated by commas and/or newlines. The value
/// keeps any `=` after the first (so `Authorization=Bearer x=y` → `Bearer /// keeps any `=` after the first (so `Authorization=Bearer x=y` → `Bearer
/// x=y`). Byte-identical logic to `hive-c0re::stats::otel_metrics::parse_kv` /// x=y`). Byte-identical logic to `hive-c0re::stats::otel_metrics::parse_kv`
/// — small enough that a shared crate for it would cost more than it saves, /// — small enough that sharing it isn't worth a dependency edge, but kept
/// but kept in lockstep on purpose. /// in lockstep on purpose.
fn parse_kv(s: &str) -> Vec<(String, String)> { fn parse_kv(s: &str) -> Vec<(String, String)> {
s.split([',', '\n']) s.split([',', '\n'])
.filter_map(|pair| { .filter_map(|pair| {
@ -260,7 +249,7 @@ mod tests {
} }
/// `state_attr` must spell the exact variant name — a Grafana query /// `state_attr` must spell the exact variant name — a Grafana query
/// filtering `state="Running"` should match what this daemon actually /// filtering `state="Running"` should match what this crate actually
/// emits, not a re-cased or re-worded version of it. /// emits, not a re-cased or re-worded version of it.
#[test] #[test]
fn state_attr_spells_the_variant_name() { fn state_attr_spells_the_variant_name() {

View file

@ -38,12 +38,12 @@ problem_details = { version = "0.9.0", features = ["axum"] }
# same shape `hive-c0re/src/job_queue/scheduler.rs` uses over its own graph. # same shape `hive-c0re/src/job_queue/scheduler.rs` uses over its own graph.
hive-jobq.workspace = true hive-jobq.workspace = true
hive-jobq-wire.workspace = true hive-jobq-wire.workspace = true
# OTEL SDK for the jobq-rollup metrics exporter (`jobq_metrics.rs`), from # The jobq-rollup OTEL exporter, wired up in `main` via
# the workspace base — see the root Cargo.toml's comment for the # `hive_jobq_metrics::spawn_exporter` — moved to its own crate (rather than
# blocking-client rationale shared by every OTEL-pushing crate. # living here as `jobq_metrics.rs`) specifically so a future second caller
opentelemetry.workspace = true # (e.g. hive-c0re, for its own per-hive job graph) doesn't have to depend on
opentelemetry_sdk.workspace = true # this whole binary to reuse it.
opentelemetry-otlp.workspace = true hive-jobq-metrics.workspace = true
# The forge webhook HMAC (`webhook.rs`). Kept in this crate rather than # The forge webhook HMAC (`webhook.rs`). Kept in this crate rather than
# shared with hive-c0re's equivalent: c0re's copy is scheduled to be deleted # shared with hive-c0re's equivalent: c0re's copy is scheduled to be deleted
# with its webhook routes once registration moves here, so the second holder # with its webhook routes once registration moves here, so the second holder

View file

@ -37,7 +37,6 @@ use utoipa_axum::{router::OpenApiRouter, routes};
mod auth; mod auth;
mod forge; mod forge;
mod jobq_metrics;
mod status; mod status;
mod webhook; mod webhook;
@ -921,7 +920,8 @@ async fn main() -> Result<()> {
// `PeriodicReader`, so it must live as long as `main` does (which it // `PeriodicReader`, so it must live as long as `main` does (which it
// does here: this binding never goes out of scope before the process // does here: this binding never goes out of scope before the process
// exits via `axum::serve(...).await` below). // exits via `axum::serve(...).await` below).
let _jobq_metrics_provider = jobq_metrics::spawn_exporter(Arc::clone(&jobq)); let _jobq_metrics_provider =
hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller");
// Same "log and carry on" shape as the queue/bridge/forge wiring above. // Same "log and carry on" shape as the queue/bridge/forge wiring above.
// A controller that cannot hold a webhook secret still serves every // A controller that cannot hold a webhook secret still serves every