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",
]
[[package]]
name = "hive-jobq-metrics"
version = "0.1.0"
dependencies = [
"anyhow",
"hive-jobq",
"hive-jobq-wire",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"tokio",
"tracing",
]
[[package]]
name = "hive-jobq-wire"
version = "0.1.0"
@ -4558,12 +4572,10 @@ dependencies = [
"forgejo-api",
"futures-util",
"hive-jobq",
"hive-jobq-metrics",
"hive-jobq-wire",
"hive-types",
"hmac 0.13.0",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"problem_details",
"reqwest",
"serde",

View file

@ -12,6 +12,7 @@ members = [
"hive-forge-notify",
"hive-host-sock",
"hive-jobq",
"hive-jobq-metrics",
"hive-jobq-wire",
"hive-matrix-mcp",
"hive-metric",
@ -80,6 +81,7 @@ indicatif = "0.18"
hive-sh4re = { path = "hive-sh4re" }
hive-agent-sock = { path = "hive-agent-sock" }
hive-jobq = { path = "hive-jobq" }
hive-jobq-metrics = { path = "hive-jobq-metrics" }
hive-jobq-wire = { path = "hive-jobq-wire" }
hive-core-agent-sock = { path = "hive-core-agent-sock" }
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
//! `hive_jobq_wire::state_rollup()` counts `GET /api/jobq/rollup` already
//! serves, ridden out to the collector on a timer instead of only on
//! request. First consumer of a generic "add jobq metrics" ask: [`spawn_exporter`]
//! is generic over any `hive_jobq::scheduler::Scheduler<N, R>`, not tied to
//! 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.
//! OTEL export of a [`hive_jobq`] graph's state rollup — the same
//! [`hive_jobq_wire::state_rollup()`] counts a viewer's rollup endpoint
//! already serves, ridden out to the collector on a timer instead of only
//! on request. See the crate README for why this lives in its own crate
//! rather than inside `hive-jobq` or `hive-jobq-wire`.
//!
//! Same OTEL SDK setup as `hive-c0re::stats::otel_metrics` (container
//! 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
//! `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
//! handler that also locks it for however long the export takes. So, same
//! pattern as the container-stats exporter: an async task refreshes a
//! shared snapshot on an interval, and the (sync) SDK callbacks only ever
//! read that snapshot, never the scheduler directly.
//! handler that also locks it for however long the export takes. So an
//! async task refreshes a shared snapshot on an interval, and the (sync)
//! SDK callbacks only ever read that snapshot, never the scheduler
//! directly.
use std::hash::Hash;
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.
/// 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);
/// Spawn a jobq-rollup OTEL exporter for `jobq` if OTEL is configured
/// (`OTEL_EXPORTER_OTLP_ENDPOINT` non-empty) — `None` otherwise, same
/// graceful-absence shape every other optional wiring in this daemon uses
/// (queue, bridge, forge, webhook secret).
/// graceful-absence shape optional OTEL wiring uses elsewhere in this tree.
/// `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
/// the `SdkMeterProvider` behind `static OnceLock`s, which quietly made this
/// a swarm-controller singleton — a second call (a second graph, a test, a
/// future host with more than one jobq instance) would have silently reused
/// the first call's state instead of exporting its own. The snapshot is now
/// a plain local `Arc`, and the provider is returned rather than stashed —
/// **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`.
/// **No process-global state.** The snapshot is a plain local `Arc`, and
/// the provider is returned rather than stashed in a static — **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`. Call this once per graph you want exported, from as
/// many call sites as you like — nothing here assumes there's only one.
///
/// Generic over `N`/`R` (the same parameters `Scheduler` itself takes)
/// rather than this crate's own `SwarmNodeKind`/`SwarmResourceKind` — the
/// whole point of the ask was a shape any `hive-jobq` host can plug in, not
/// one hardcoded to this crate's node/resource vocabulary.
/// Generic over `N`/`R` (the same parameters [`hive_jobq::scheduler::Scheduler`]
/// itself takes) rather than any one host's node/resource vocabulary — the
/// whole point of this crate is a shape any `hive-jobq` host can plug in.
pub fn spawn_exporter<N, R>(
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<N, R>>>,
service_name: &str,
) -> Option<SdkMeterProvider>
where
N: Send + 'static,
@ -87,9 +83,9 @@ where
}
});
match build_provider(interval, snapshot) {
match build_provider(interval, snapshot, service_name) {
Ok(provider) => {
tracing::info!(%endpoint, ?interval, "otel jobq-metrics: exporter enabled");
tracing::info!(%endpoint, ?interval, %service_name, "otel jobq-metrics: exporter enabled");
Some(provider)
}
Err(e) => {
@ -102,6 +98,7 @@ where
fn build_provider(
interval: Duration,
snapshot: Arc<Mutex<Vec<StateCount>>>,
service_name: &str,
) -> Result<SdkMeterProvider> {
// Same http/json, endpoint-from-env-only construction as
// `hive-c0re::stats::otel_metrics` — see that module's doc comment for
@ -116,7 +113,7 @@ fn build_provider(
.build();
let provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource())
.with_resource(resource(service_name))
.build();
register_instruments(&provider, snapshot);
Ok(provider)
@ -164,19 +161,11 @@ fn state_attr(c: &StateCount) -> KeyValue {
KeyValue::new("state", format!("{:?}", c.state))
}
/// Resource: `service.name = swarm-controller` plus whatever the operator
/// set in `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES` (same channel every
/// other 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
/// beyond what the operator supplies).
///
/// 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");
/// Resource: `service.name = service_name` plus whatever the operator set
/// in `HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES` (same channel every other
/// OTEL exporter in this tree reads its `hive`/`swarm` labels from).
fn resource(service_name: &str) -> Resource {
let mut builder = Resource::builder().with_service_name(service_name.to_owned());
for (k, v) in resource_attributes() {
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
/// keeps any `=` after the first (so `Authorization=Bearer x=y` → `Bearer
/// 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,
/// but kept in lockstep on purpose.
/// — small enough that sharing it isn't worth a dependency edge, but kept
/// in lockstep on purpose.
fn parse_kv(s: &str) -> Vec<(String, String)> {
s.split([',', '\n'])
.filter_map(|pair| {
@ -260,7 +249,7 @@ mod tests {
}
/// `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.
#[test]
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.
hive-jobq.workspace = true
hive-jobq-wire.workspace = true
# OTEL SDK for the jobq-rollup metrics exporter (`jobq_metrics.rs`), from
# the workspace base — see the root Cargo.toml's comment for the
# blocking-client rationale shared by every OTEL-pushing crate.
opentelemetry.workspace = true
opentelemetry_sdk.workspace = true
opentelemetry-otlp.workspace = true
# The jobq-rollup OTEL exporter, wired up in `main` via
# `hive_jobq_metrics::spawn_exporter` — moved to its own crate (rather than
# living here as `jobq_metrics.rs`) specifically so a future second caller
# (e.g. hive-c0re, for its own per-hive job graph) doesn't have to depend on
# this whole binary to reuse it.
hive-jobq-metrics.workspace = true
# 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
# 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 forge;
mod jobq_metrics;
mod status;
mod webhook;
@ -921,7 +920,8 @@ async fn main() -> Result<()> {
// `PeriodicReader`, so it must live as long as `main` does (which it
// does here: this binding never goes out of scope before the process
// 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.
// A controller that cannot hold a webhook secret still serves every