//! `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() }