swarm-controller: extract jobq_metrics into its own hive-jobq-metrics crate
This commit is contained in:
parent
621245306f
commit
f5ff8698f3
7 changed files with 100 additions and 59 deletions
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
18
hive-jobq-metrics/Cargo.toml
Normal file
18
hive-jobq-metrics/Cargo.toml
Normal 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
|
||||||
20
hive-jobq-metrics/README.md
Normal file
20
hive-jobq-metrics/README.md
Normal 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`).
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue