From 39fc088907751893f3db208f22f62242d39631f6 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 4 Jul 2026 22:48:56 +0200 Subject: [PATCH 1/9] feat(#2100): add hive-metric OTLP CLI for agent-emitted custom metrics --- Cargo.lock | 11 +++ Cargo.toml | 1 + docs/observability.md | 50 ++++++++++++ hive-metric/Cargo.toml | 15 ++++ hive-metric/src/main.rs | 169 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 hive-metric/Cargo.toml create mode 100644 hive-metric/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 03443d9a..3ba044a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1514,6 +1514,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hive-metric" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "reqwest", + "serde_json", + "tokio", +] + [[package]] name = "hive-priv" version = "0.1.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..95413e21 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -125,6 +125,56 @@ Every agent's export includes these resource attributes automatically: Additional labels can be appended via `extraResourceAttributes`. +Additional labels can be appended per-agent 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 gauge|counter] [--labels key=value...] +``` + +- `` — metric name (e.g. `tasks_completed`, `latency_ms`). +- `` — numeric value (f64; integers and floats both accepted). +- `--type gauge|counter` — metric kind: `gauge` (instantaneous, default) or + `counter` (monotonically increasing cumulative sum). +- `--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 +# Gauge: current queue depth +hive-metric queue_depth 17 + +# Counter: cumulative tasks finished, with a custom label +hive-metric tasks_completed 1 --type counter --labels phase=scan + +# Float gauge with multiple labels +hive-metric api_latency_ms 142.5 --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 OTEL export is always configured with **cumulative** temporality diff --git a/hive-metric/Cargo.toml b/hive-metric/Cargo.toml new file mode 100644 index 00000000..e5790915 --- /dev/null +++ b/hive-metric/Cargo.toml @@ -0,0 +1,15 @@ +[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 +reqwest.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs new file mode 100644 index 00000000..46f06037 --- /dev/null +++ b/hive-metric/src/main.rs @@ -0,0 +1,169 @@ +//! `hive-metric` — push a single labeled metric to the OTEL collector. +//! +//! Reads `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` +//! from the environment (set by the harness via claude's managed settings +//! when `services.hyperhive.otel.enable = true`). Resource labels (agent, +//! hive, swarm, service.name) are inherited from `OTEL_RESOURCE_ATTRIBUTES`, +//! which the harness already populates per-agent — the caller does not need +//! to re-specify them. +//! +//! # Usage +//! ```text +//! hive-metric [--type gauge|counter] [--labels key=value...] +//! ``` +//! +//! # Examples +//! ```text +//! hive-metric tasks_completed 42 +//! hive-metric errors_total 3 --type counter --labels phase=scan +//! hive-metric latency_ms 250.5 --labels model=sonnet --labels tier=api +//! ``` + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde_json::{Value, json}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Parser)] +#[command( + name = "hive-metric", + about = "Push a labeled metric to the OTEL collector (requires services.hyperhive.otel.enable = true)" +)] +struct Cli { + /// Metric name (e.g. `tasks_completed`, `errors_total`). + name: String, + + /// Numeric value (f64). Floats and integers both accepted. + value: f64, + + /// Metric kind: `gauge` (instantaneous value, default) or `counter` + /// (monotonically increasing sum, cumulative temporality). + #[arg(long = "type", value_name = "TYPE", default_value = "gauge")] + metric_type: MetricKind, + + /// Extra label(s) as `key=value` pairs. May be repeated. + /// Resource labels (agent, hive, swarm) are inherited automatically + /// from `OTEL_RESOURCE_ATTRIBUTES` and must not be duplicated here. + #[arg(long, value_name = "KEY=VALUE")] + labels: Vec, +} + +#[derive(Clone, Debug, clap::ValueEnum)] +enum MetricKind { + /// Gauge: represents a value at a point in time (default). + Gauge, + /// Counter: monotonically increasing cumulative sum. + Counter, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").context( + "OTEL_EXPORTER_OTLP_ENDPOINT not set — enable OTEL in NixOS config \ + (services.hyperhive.otel.enable = true)", + )?; + let metrics_url = format!("{}/v1/metrics", endpoint.trim_end_matches('/')); + + let now_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_string(); + + let resource_attrs = parse_kv_list(&std::env::var("OTEL_RESOURCE_ATTRIBUTES").unwrap_or_default(), ','); + let data_attrs = parse_label_args(&cli.labels)?; + + let data_point = json!({ + "attributes": data_attrs, + "startTimeUnixNano": now_ns, + "timeUnixNano": now_ns, + "asDouble": cli.value, + }); + + let metric = match cli.metric_type { + MetricKind::Gauge => json!({ + "name": cli.name, + "gauge": { "dataPoints": [data_point] }, + }), + MetricKind::Counter => json!({ + "name": cli.name, + "sum": { + "dataPoints": [data_point], + // AGGREGATION_TEMPORALITY_CUMULATIVE = 2 + "aggregationTemporality": 2, + "isMonotonic": true, + }, + }), + }; + + let payload = json!({ + "resourceMetrics": [{ + "resource": { "attributes": resource_attrs }, + "scopeMetrics": [{ + "scope": { "name": "hive-metric", "version": "0.1.0" }, + "metrics": [metric], + }], + }] + }); + + let mut builder = reqwest::Client::new() + .post(&metrics_url) + .header("content-type", "application/json") + .json(&payload); + + // OTEL_EXPORTER_OTLP_HEADERS: `Key=Value,Key2=Value2` (comma-separated). + if let Ok(h) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") { + for (k, v) in parse_kv_list(&h, ',') + .into_iter() + .filter_map(|kv| { + let k = kv.get("key")?.as_str()?.to_owned(); + let v = kv + .get("value")? + .get("stringValue")? + .as_str()? + .to_owned(); + Some((k, v)) + }) + { + builder = builder.header(k, v); + } + } + + let resp = builder + .send() + .await + .with_context(|| format!("POST to {metrics_url} failed"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + bail!("OTEL endpoint {metrics_url} returned {status}: {body}"); + } + + Ok(()) +} + +/// Parse a `key=value` delimited list into OTLP attribute objects. +/// Entries that lack `=` are silently skipped. +fn parse_kv_list(s: &str, sep: char) -> Vec { + s.split(sep) + .filter_map(|entry| { + let (k, v) = entry.split_once('=')?; + Some(json!({ "key": k.trim(), "value": { "stringValue": v.trim() } })) + }) + .collect() +} + +/// Parse `--labels k=v` CLI args into OTLP attribute objects. +fn parse_label_args(args: &[String]) -> Result> { + args.iter() + .map(|s| { + let (k, v) = s + .split_once('=') + .with_context(|| format!("--labels value must be key=value, got {s:?}"))?; + Ok(json!({ "key": k.trim(), "value": { "stringValue": v.trim() } })) + }) + .collect() +} From b4b5dd7fa46e0e66f99298c789de7b158c7bc2c7 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 4 Jul 2026 23:00:06 +0200 Subject: [PATCH 2/9] fix(hive-metric): use opentelemetry-otlp SDK instead of hand-rolled OTLP --- hive-metric/Cargo.toml | 11 ++- hive-metric/src/main.rs | 155 ++++++++++++---------------------------- 2 files changed, 55 insertions(+), 111 deletions(-) diff --git a/hive-metric/Cargo.toml b/hive-metric/Cargo.toml index e5790915..794d135f 100644 --- a/hive-metric/Cargo.toml +++ b/hive-metric/Cargo.toml @@ -10,6 +10,13 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true -reqwest.workspace = true -serde_json.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"] } +opentelemetry-otlp = { version = "0.32", default-features = false, features = [ + "metrics", + "http-json", + "reqwest-client", + "reqwest-rustls", +] } tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs index 46f06037..e7b8cd8b 100644 --- a/hive-metric/src/main.rs +++ b/hive-metric/src/main.rs @@ -1,28 +1,25 @@ //! `hive-metric` — push a single labeled metric to the OTEL collector. //! -//! Reads `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` -//! from the environment (set by the harness via claude's managed settings -//! when `services.hyperhive.otel.enable = true`). Resource labels (agent, -//! hive, swarm, service.name) are inherited from `OTEL_RESOURCE_ATTRIBUTES`, -//! which the harness already populates per-agent — the caller does not need -//! to re-specify them. +//! 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 gauge|counter] [--labels key=value...] //! ``` -//! -//! # Examples -//! ```text -//! hive-metric tasks_completed 42 -//! hive-metric errors_total 3 --type counter --labels phase=scan -//! hive-metric latency_ms 250.5 --labels model=sonnet --labels tier=api -//! ``` use anyhow::{Context, Result, bail}; use clap::Parser; -use serde_json::{Value, json}; -use std::time::{SystemTime, UNIX_EPOCH}; +use opentelemetry::KeyValue; +use opentelemetry::metrics::MeterProvider; +use opentelemetry_otlp::{Protocol, WithExportConfig}; #[derive(Parser)] #[command( @@ -30,27 +27,26 @@ use std::time::{SystemTime, UNIX_EPOCH}; about = "Push a labeled metric to the OTEL collector (requires services.hyperhive.otel.enable = true)" )] struct Cli { - /// Metric name (e.g. `tasks_completed`, `errors_total`). + /// Metric name (e.g. `tasks_completed`, `latency_ms`). name: String, /// Numeric value (f64). Floats and integers both accepted. value: f64, - /// Metric kind: `gauge` (instantaneous value, default) or `counter` - /// (monotonically increasing sum, cumulative temporality). + /// Metric kind: `gauge` (instantaneous, default) or `counter` (cumulative sum). #[arg(long = "type", value_name = "TYPE", default_value = "gauge")] metric_type: MetricKind, /// Extra label(s) as `key=value` pairs. May be repeated. - /// Resource labels (agent, hive, swarm) are inherited automatically - /// from `OTEL_RESOURCE_ATTRIBUTES` and must not be duplicated here. + /// 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 { - /// Gauge: represents a value at a point in time (default). + /// Gauge: point-in-time value (default). Gauge, /// Counter: monotonically increasing cumulative sum. Counter, @@ -60,110 +56,51 @@ enum MetricKind { async fn main() -> Result<()> { let cli = Cli::parse(); - let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").context( - "OTEL_EXPORTER_OTLP_ENDPOINT not set — enable OTEL in NixOS config \ - (services.hyperhive.otel.enable = true)", - )?; - let metrics_url = format!("{}/v1/metrics", endpoint.trim_end_matches('/')); + // 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 now_ns = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - .to_string(); + let labels = parse_labels(&cli.labels)?; - let resource_attrs = parse_kv_list(&std::env::var("OTEL_RESOURCE_ATTRIBUTES").unwrap_or_default(), ','); - let data_attrs = parse_label_args(&cli.labels)?; + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_protocol(Protocol::HttpJson) + .build() + .context("failed to build OTLP metric exporter")?; - let data_point = json!({ - "attributes": data_attrs, - "startTimeUnixNano": now_ns, - "timeUnixNano": now_ns, - "asDouble": cli.value, - }); + // SDK automatically reads OTEL_RESOURCE_ATTRIBUTES for resource labels. + let provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder() + .with_periodic_exporter(exporter) + .build(); - let metric = match cli.metric_type { - MetricKind::Gauge => json!({ - "name": cli.name, - "gauge": { "dataPoints": [data_point] }, - }), - MetricKind::Counter => json!({ - "name": cli.name, - "sum": { - "dataPoints": [data_point], - // AGGREGATION_TEMPORALITY_CUMULATIVE = 2 - "aggregationTemporality": 2, - "isMonotonic": true, - }, - }), - }; + let meter = provider.meter("hive-metric"); - let payload = json!({ - "resourceMetrics": [{ - "resource": { "attributes": resource_attrs }, - "scopeMetrics": [{ - "scope": { "name": "hive-metric", "version": "0.1.0" }, - "metrics": [metric], - }], - }] - }); - - let mut builder = reqwest::Client::new() - .post(&metrics_url) - .header("content-type", "application/json") - .json(&payload); - - // OTEL_EXPORTER_OTLP_HEADERS: `Key=Value,Key2=Value2` (comma-separated). - if let Ok(h) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") { - for (k, v) in parse_kv_list(&h, ',') - .into_iter() - .filter_map(|kv| { - let k = kv.get("key")?.as_str()?.to_owned(); - let v = kv - .get("value")? - .get("stringValue")? - .as_str()? - .to_owned(); - Some((k, v)) - }) - { - builder = builder.header(k, v); + match cli.metric_type { + MetricKind::Gauge => { + meter.f64_gauge(cli.name).build().record(cli.value, &labels); + } + MetricKind::Counter => { + meter.f64_counter(cli.name).build().add(cli.value, &labels); } } - let resp = builder - .send() - .await - .with_context(|| format!("POST to {metrics_url} failed"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - bail!("OTEL endpoint {metrics_url} returned {status}: {body}"); - } + // Flush pending metrics and shut down cleanly. + provider.shutdown().context("failed to flush OTLP metrics")?; Ok(()) } -/// Parse a `key=value` delimited list into OTLP attribute objects. -/// Entries that lack `=` are silently skipped. -fn parse_kv_list(s: &str, sep: char) -> Vec { - s.split(sep) - .filter_map(|entry| { - let (k, v) = entry.split_once('=')?; - Some(json!({ "key": k.trim(), "value": { "stringValue": v.trim() } })) - }) - .collect() -} - -/// Parse `--labels k=v` CLI args into OTLP attribute objects. -fn parse_label_args(args: &[String]) -> Result> { +fn parse_labels(args: &[String]) -> Result> { args.iter() .map(|s| { let (k, v) = s .split_once('=') - .with_context(|| format!("--labels value must be key=value, got {s:?}"))?; - Ok(json!({ "key": k.trim(), "value": { "stringValue": v.trim() } })) + .with_context(|| format!("--labels must be key=value, got {s:?}"))?; + Ok(KeyValue::new(k.trim().to_owned(), v.trim().to_owned())) }) .collect() } From 28605e5cdd2ea96e62a6de321928a835844b74ea Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 4 Jul 2026 23:01:43 +0200 Subject: [PATCH 3/9] docs(observability): remove duplicate extraResourceAttributes sentence --- docs/observability.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 95413e21..1982dca5 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -123,10 +123,8 @@ 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 per-agent via `extraResourceAttributes` (see -option reference above); custom per-data-point labels can be passed with +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`) From 61dfceb10033bbbaf15ea921117f0df528968759 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 5 Jul 2026 01:10:40 +0200 Subject: [PATCH 4/9] fix(hive-metric): multi-thread runtime + counter default --- docs/observability.md | 18 +++++++++--------- hive-metric/src/main.rs | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 1982dca5..c95d5d52 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -136,13 +136,13 @@ Agents can push arbitrary labeled metrics to the same OTEL collector via the ### Usage ```text -hive-metric [--type gauge|counter] [--labels key=value...] +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 gauge|counter` — metric kind: `gauge` (instantaneous, default) or - `counter` (monotonically increasing cumulative sum). +- `--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. @@ -150,14 +150,14 @@ hive-metric [--type gauge|counter] [--labels key=value...] ### Examples ```text -# Gauge: current queue depth -hive-metric queue_depth 17 +# Counter: cumulative tasks finished (default type — no --type flag needed) +hive-metric tasks_completed 1 --labels phase=scan -# Counter: cumulative tasks finished, with a custom label -hive-metric tasks_completed 1 --type counter --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 -hive-metric api_latency_ms 142.5 --labels model=sonnet --labels tier=api +# 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 diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs index e7b8cd8b..967e4c00 100644 --- a/hive-metric/src/main.rs +++ b/hive-metric/src/main.rs @@ -33,8 +33,8 @@ struct Cli { /// Numeric value (f64). Floats and integers both accepted. value: f64, - /// Metric kind: `gauge` (instantaneous, default) or `counter` (cumulative sum). - #[arg(long = "type", value_name = "TYPE", default_value = "gauge")] + /// 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. @@ -46,13 +46,13 @@ struct Cli { #[derive(Clone, Debug, clap::ValueEnum)] enum MetricKind { - /// Gauge: point-in-time value (default). - Gauge, - /// Counter: monotonically increasing cumulative sum. + /// Counter: monotonically increasing cumulative sum (default). Counter, + /// Gauge: point-in-time instantaneous value. + Gauge, } -#[tokio::main(flavor = "current_thread")] +#[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -80,12 +80,12 @@ async fn main() -> Result<()> { let meter = provider.meter("hive-metric"); match cli.metric_type { - MetricKind::Gauge => { - meter.f64_gauge(cli.name).build().record(cli.value, &labels); - } 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. From 4b8a045232a43f8ae4761e5a43db2ce93b96183e Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 5 Jul 2026 01:23:26 +0200 Subject: [PATCH 5/9] fix(hive-metric): fix usage doc order (counter|gauge) --- hive-metric/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs index 967e4c00..d298eab6 100644 --- a/hive-metric/src/main.rs +++ b/hive-metric/src/main.rs @@ -12,7 +12,7 @@ //! //! # Usage //! ```text -//! hive-metric [--type gauge|counter] [--labels key=value...] +//! hive-metric [--type counter|gauge] [--labels key=value...] //! ``` use anyhow::{Context, Result, bail}; From 4011141fd62d70ff3c2f9fccf00d6b4cd74b4fb7 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 8 Jul 2026 21:07:16 +0200 Subject: [PATCH 6/9] fix(ci): add cmake to crane build inputs for aws-lc-sys (otlp/reqwest-rustls) --- flake.nix | 6 ++++++ 1 file changed, 6 insertions(+) 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 ]; } ); From 307e4a9ad54c4d621bcf54c94becb6e7a1ca9a31 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 8 Jul 2026 22:38:29 +0200 Subject: [PATCH 7/9] style(hive-metric): treefmt shutdown() call --- hive-metric/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs index d298eab6..931509a3 100644 --- a/hive-metric/src/main.rs +++ b/hive-metric/src/main.rs @@ -89,7 +89,9 @@ async fn main() -> Result<()> { } // Flush pending metrics and shut down cleanly. - provider.shutdown().context("failed to flush OTLP metrics")?; + provider + .shutdown() + .context("failed to flush OTLP metrics")?; Ok(()) } From aa9f56246a98b9a50f1c7b7f45f5f6c15b39b277 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 10 Jul 2026 20:55:16 +0200 Subject: [PATCH 8/9] chore(hive-metric): regenerate Cargo.lock after rebase onto main --- Cargo.lock | 395 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 384 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ba044a2..c5872403 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", @@ -1520,8 +1588,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "reqwest", - "serde_json", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "tokio", ] @@ -2000,6 +2069,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" @@ -2288,7 +2416,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "reqwest", + "reqwest 0.12.28", "ruma", "serde", "serde_html_form", @@ -2581,7 +2709,7 @@ dependencies = [ "getrandom 0.2.17", "http", "rand 0.8.6", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -2614,6 +2742,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" @@ -2845,6 +3047,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" @@ -2852,7 +3069,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]] @@ -2868,6 +3095,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" @@ -2912,6 +3152,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", @@ -3038,6 +3279,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" @@ -3167,6 +3417,41 @@ 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-core", + "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" @@ -3468,6 +3753,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", @@ -3498,12 +3784,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", @@ -3521,6 +3835,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" @@ -3773,6 +4096,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" @@ -4363,6 +4702,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" @@ -4496,7 +4841,7 @@ dependencies = [ "hkdf", "hmac", "matrix-pickle", - "prost", + "prost 0.13.5", "rand 0.8.6", "serde", "serde_bytes", @@ -4508,6 +4853,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" @@ -4663,6 +5018,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" @@ -4694,6 +5058,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" From d91e6eb0e2fd3cc863a27a49292ab00ed24045e4 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 10 Jul 2026 21:17:39 +0200 Subject: [PATCH 9/9] fix(hive-metric): blocking OTLP client (no-reactor panic), reject negative counters, allow negative gauges --- Cargo.lock | 3 ++- hive-metric/Cargo.toml | 8 ++++++-- hive-metric/src/main.rs | 31 +++++++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5872403..e505921b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,7 +1591,6 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "tokio", ] [[package]] @@ -3425,7 +3424,9 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", diff --git a/hive-metric/Cargo.toml b/hive-metric/Cargo.toml index 794d135f..d71614be 100644 --- a/hive-metric/Cargo.toml +++ b/hive-metric/Cargo.toml @@ -13,10 +13,14 @@ 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-client", + "reqwest-blocking-client", "reqwest-rustls", ] } -tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/hive-metric/src/main.rs b/hive-metric/src/main.rs index 931509a3..48e066eb 100644 --- a/hive-metric/src/main.rs +++ b/hive-metric/src/main.rs @@ -24,13 +24,19 @@ 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)" + 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. + /// 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). @@ -52,8 +58,7 @@ enum MetricKind { Gauge, } -#[tokio::main] -async fn main() -> Result<()> { +fn main() -> Result<()> { let cli = Cli::parse(); // Fail early with a clear message when OTEL is not configured. @@ -66,6 +71,18 @@ async fn main() -> Result<()> { 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) @@ -73,6 +90,12 @@ async fn main() -> Result<()> { .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();