diff --git a/Cargo.lock b/Cargo.lock index 03443d9a..e505921b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b06e72c9..c9b9238d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "hive-claude", "hive-forge", "hive-matrix-mcp", + "hive-metric", "hive-priv", "hive-sh4re", ] diff --git a/docs/observability.md b/docs/observability.md index 25e7e358..c95d5d52 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -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 [--type counter|gauge] [--labels key=value...] +``` + +- `` — metric name (e.g. `tasks_completed`, `latency_ms`). +- `` — 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 diff --git a/flake.nix b/flake.nix index 8aca61c7..a4b9e0e7 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ]; } ); diff --git a/hive-metric/Cargo.toml b/hive-metric/Cargo.toml new file mode 100644 index 00000000..d71614be --- /dev/null +++ b/hive-metric/Cargo.toml @@ -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", +] } diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs new file mode 100644 index 00000000..48e066eb --- /dev/null +++ b/hive-metric/src/main.rs @@ -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 [--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, +} + +#[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> { + 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() +}