Compare commits

...
6 changed files with 607 additions and 10 deletions

403
Cargo.lock generated
View file

@ -237,6 +237,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.8.9"
@ -434,6 +457,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -577,12 +602,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.38"
@ -622,6 +666,18 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "const-hex"
version = "1.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"proptest",
"serde_core",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -910,6 +966,12 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -1105,7 +1167,7 @@ dependencies = [
"base64ct",
"bytes",
"futures",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"soft_assert",
@ -1126,6 +1188,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@ -1405,7 +1473,7 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"reqwest",
"reqwest 0.12.28",
"rmcp",
"rusqlite",
"schemars",
@ -1457,7 +1525,7 @@ dependencies = [
"listenfd",
"petgraph",
"problem_details",
"reqwest",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
@ -1487,7 +1555,7 @@ dependencies = [
"anyhow",
"clap",
"forgejo-api",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"time",
@ -1504,7 +1572,7 @@ dependencies = [
"matrix-sdk",
"mime",
"mime_guess",
"reqwest",
"reqwest 0.12.28",
"rmcp",
"schemars",
"serde",
@ -1514,6 +1582,17 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "hive-metric"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
]
[[package]]
name = "hive-priv"
version = "0.1.0"
@ -1989,6 +2068,65 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.2",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.99"
@ -2277,7 +2415,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"reqwest",
"reqwest 0.12.28",
"ruma",
"serde",
"serde_html_form",
@ -2570,7 +2708,7 @@ dependencies = [
"getrandom 0.2.17",
"http",
"rand 0.8.6",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_path_to_error",
@ -2603,6 +2741,80 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-http"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest 0.13.4",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
dependencies = [
"http",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost 0.14.4",
"reqwest 0.13.4",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "opentelemetry-proto"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
dependencies = [
"base64",
"const-hex",
"opentelemetry",
"opentelemetry_sdk",
"prost 0.14.4",
"serde",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.4",
"thiserror 2.0.18",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -2834,6 +3046,21 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bitflags",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"unarray",
]
[[package]]
name = "prost"
version = "0.13.5"
@ -2841,7 +3068,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive 0.14.4",
]
[[package]]
@ -2857,6 +3094,19 @@ dependencies = [
"syn",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@ -2901,6 +3151,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@ -3027,6 +3278,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
@ -3156,6 +3416,43 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -3457,6 +3754,7 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
@ -3487,12 +3785,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -3510,6 +3836,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
@ -3762,6 +4097,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@ -4352,6 +4703,12 @@ dependencies = [
"web-time",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
@ -4485,7 +4842,7 @@ dependencies = [
"hkdf",
"hmac",
"matrix-pickle",
"prost",
"prost 0.13.5",
"rand 0.8.6",
"serde",
"serde_bytes",
@ -4497,6 +4854,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@ -4652,6 +5019,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@ -4683,6 +5059,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"

View file

@ -7,6 +7,7 @@ members = [
"hive-claude",
"hive-forge",
"hive-matrix-mcp",
"hive-metric",
"hive-priv",
"hive-sh4re",
]

View file

@ -123,7 +123,55 @@ Every agent's export includes these resource attributes automatically:
| `hive` | hive display name (`services.hyperhive.hiveName`) |
| `swarm` | swarm display name (`services.hyperhive.swarmName`, if set) |
Additional labels can be appended via `extraResourceAttributes`.
Additional labels can be appended via `extraResourceAttributes` (see option
reference above); custom per-data-point labels can be passed with
`hive-metric --labels` (see below).
## Agent-emitted custom metrics (`hive-metric`)
Agents can push arbitrary labeled metrics to the same OTEL collector via the
`hive-metric` CLI tool, available in every agent container when
`services.hyperhive.otel.enable = true`.
### Usage
```text
hive-metric <name> <value> [--type counter|gauge] [--labels key=value...]
```
- `<name>` — metric name (e.g. `tasks_completed`, `latency_ms`).
- `<value>` — numeric value (f64; integers and floats both accepted).
- `--type counter|gauge` — metric kind: `counter` (cumulative sum, default) or
`gauge` (instantaneous point-in-time value).
- `--labels key=value` — extra per-data-point labels. May be repeated.
The resource labels (agent, hive, swarm, service.name) are inherited
automatically from `OTEL_RESOURCE_ATTRIBUTES` — do not re-specify them.
### Examples
```text
# Counter: cumulative tasks finished (default type — no --type flag needed)
hive-metric tasks_completed 1 --labels phase=scan
# Gauge: current queue depth (absolute value — must use --type gauge)
hive-metric queue_depth 17 --type gauge
# Float gauge with multiple labels (instantaneous measurement)
hive-metric api_latency_ms 142.5 --type gauge --labels model=sonnet --labels tier=api
```
### Error when OTEL is not configured
When `services.hyperhive.otel.enable = false` (the default), the
`OTEL_EXPORTER_OTLP_ENDPOINT` env var is not set and `hive-metric` exits
with an informative error message. No silently-dropped metrics.
### Wire format
`hive-metric` always uses **OTLP HTTP/JSON** (`application/json` POST to
`$OTEL_EXPORTER_OTLP_ENDPOINT/v1/metrics`), regardless of the
`OTEL_EXPORTER_OTLP_PROTOCOL` setting. Auth headers from
`OTEL_EXPORTER_OTLP_HEADERS` are forwarded verbatim.
## Metrics temporality

View file

@ -141,10 +141,16 @@
# (`hive-matrix-mcp` workspace member) — the
# matrix-sdk-sqlite + rusqlite stack links against system
# libsqlite3 by default.
# `cmake` builds `aws-lc-sys` (BoringSSL) from source — pulled in by
# the `rustls` (aws-lc-rs) crypto provider under the OTLP/reqwest
# stack in `hive-metric`. Without it the crane deps build fails on
# `cargo-package-reqwest-0.13.4`. Kept in the shared inputs since
# more metric/crypto deps are expected to land in the workspace.
nativeBuildInputs = [
pkgs.git
pkgs.sqlite
pkgs.pkg-config
pkgs.cmake
];
}
);

26
hive-metric/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "hive-metric"
version.workspace = true
edition.workspace = true
[[bin]]
name = "hive-metric"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
# OTEL SDK — only used by this crate, so kept local rather than in workspace.dependencies.
opentelemetry = "0.32"
opentelemetry_sdk = { version = "0.32", features = ["metrics"] }
# Blocking (not async) reqwest client on purpose: the metrics SDK drives the
# OTLP push from a `PeriodicReader` background thread that has no Tokio runtime,
# so the async client panics there with "no reactor running". The blocking
# client sends on that thread directly — and for a fire-and-forget CLI that
# records one point then `shutdown()`s, a synchronous send is exactly right.
opentelemetry-otlp = { version = "0.32", default-features = false, features = [
"metrics",
"http-json",
"reqwest-blocking-client",
"reqwest-rustls",
] }

131
hive-metric/src/main.rs Normal file
View file

@ -0,0 +1,131 @@
//! `hive-metric` — push a single labeled metric to the OTEL collector.
//!
//! Uses the OpenTelemetry Rust SDK with the OTLP HTTP/JSON exporter to emit
//! metrics. Standard OTEL env vars are read automatically by the SDK:
//!
//! - `OTEL_EXPORTER_OTLP_ENDPOINT` — collector URL (required)
//! - `OTEL_EXPORTER_OTLP_HEADERS` — auth headers (`Key=Value,...`)
//! - `OTEL_RESOURCE_ATTRIBUTES` — resource labels (`k=v,...`)
//!
//! The harness populates all of these per-agent when
//! `services.hyperhive.otel.enable = true`.
//!
//! # Usage
//! ```text
//! hive-metric <name> <value> [--type counter|gauge] [--labels key=value...]
//! ```
use anyhow::{Context, Result, bail};
use clap::Parser;
use opentelemetry::KeyValue;
use opentelemetry::metrics::MeterProvider;
use opentelemetry_otlp::{Protocol, WithExportConfig};
#[derive(Parser)]
#[command(
name = "hive-metric",
about = "Push a labeled metric to the OTEL collector (requires services.hyperhive.otel.enable = true)",
// Let a leading-minus value parse as the `value` positional rather than
// being rejected as an unknown flag — negative gauges are legitimate
// (e.g. a delta), and it lets the counter-sign check below produce a clear
// domain error instead of clap's "unexpected argument '-1'".
allow_negative_numbers = true
)]
struct Cli {
/// Metric name (e.g. `tasks_completed`, `latency_ms`).
name: String,
/// Numeric value (f64). Floats and integers both accepted. Gauges may be
/// negative; counters must be >= 0 (they only increase).
value: f64,
/// Metric kind: `counter` (cumulative sum, default) or `gauge` (instantaneous value).
#[arg(long = "type", value_name = "TYPE", default_value = "counter")]
metric_type: MetricKind,
/// Extra label(s) as `key=value` pairs. May be repeated.
/// Resource labels (agent, hive, swarm) come from `OTEL_RESOURCE_ATTRIBUTES`
/// automatically — do not re-specify them here.
#[arg(long, value_name = "KEY=VALUE")]
labels: Vec<String>,
}
#[derive(Clone, Debug, clap::ValueEnum)]
enum MetricKind {
/// Counter: monotonically increasing cumulative sum (default).
Counter,
/// Gauge: point-in-time instantaneous value.
Gauge,
}
fn main() -> Result<()> {
let cli = Cli::parse();
// Fail early with a clear message when OTEL is not configured.
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_err() {
bail!(
"OTEL_EXPORTER_OTLP_ENDPOINT not set — \
enable OTEL in NixOS config (services.hyperhive.otel.enable = true)"
);
}
let labels = parse_labels(&cli.labels)?;
// Counters are monotonically non-negative — the OTEL SDK silently drops a
// negative `add`, so a typo like `hive-metric tasks_done -1` would vanish
// with exit 0. Reject it loudly instead.
if matches!(cli.metric_type, MetricKind::Counter) && cli.value < 0.0 {
bail!(
"counter '{}' value must be >= 0 (got {}); counters only increase — \
use `--type gauge` for a value that can go down",
cli.name,
cli.value
);
}
let exporter = opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_protocol(Protocol::HttpJson)
.build()
.context("failed to build OTLP metric exporter")?;
// SDK automatically reads OTEL_RESOURCE_ATTRIBUTES for resource labels.
// The metrics SDK has no `with_simple_exporter` (that's a *traces*-only
// API) — a push exporter is always driven by a periodic reader. For this
// fire-and-forget CLI the timer never actually fires: we record one metric
// and immediately `shutdown()`, which force-flushes the pending export
// synchronously before returning. So there's no background-timer flush race
// in practice — the single point is exported exactly once on shutdown.
let provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
.with_periodic_exporter(exporter)
.build();
let meter = provider.meter("hive-metric");
match cli.metric_type {
MetricKind::Counter => {
meter.f64_counter(cli.name).build().add(cli.value, &labels);
}
MetricKind::Gauge => {
meter.f64_gauge(cli.name).build().record(cli.value, &labels);
}
}
// Flush pending metrics and shut down cleanly.
provider
.shutdown()
.context("failed to flush OTLP metrics")?;
Ok(())
}
fn parse_labels(args: &[String]) -> Result<Vec<KeyValue>> {
args.iter()
.map(|s| {
let (k, v) = s
.split_once('=')
.with_context(|| format!("--labels must be key=value, got {s:?}"))?;
Ok(KeyValue::new(k.trim().to_owned(), v.trim().to_owned()))
})
.collect()
}