131 lines
4.7 KiB
Rust
131 lines
4.7 KiB
Rust
//! `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()
|
|
}
|