Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: export ACP-reported cost and context fill over OTLP

An ACP agent's `usage_update` carries `cost.{amount,currency}`, the
session's running total (opencode sums every assistant message in the
session). hive-runtime now reads it and turns the running total into
what each report added: a new session counts from zero, a session loaded
into a freshly started agent only baselines on its first report, and a
falling total adds nothing. The spend is held on the runtime until
`Runtime::take_reported_cost` drains it; claude's runtime reports none,
since the claude binary already exports `claude_code.cost.usage`.

hive-agent's existing turn-metrics meter records three new instruments:

- `hyperhive.agent.cost.usage` (counter, `model` + `currency`), ACP only;
- `hyperhive.agent.context.used` / `.size` (gauges, no attributes), for
  every backend: the two numbers the web UI's ctx% divides.

The `hyperhive · agents` dashboard gets ACP cost panels on its cost tab
and a context-fill panel on its health tab.

Refs #4845
This commit is contained in:
atlas 2026-09-30 22:23:01 +02:00
commit c2bdf30e05
8 changed files with 605 additions and 26 deletions

View file

@ -1001,6 +1001,7 @@ async fn handle_turn<S: Surface>(
let ended_at = chrono::Utc::now().timestamp();
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
let context_window = bus.effective_context_window(&model_at_start);
let row = serve_common::build_row(serve_common::TurnRowArgs {
started_at,
ended_at,
@ -1014,10 +1015,17 @@ async fn handle_turn<S: Surface>(
open_reminders_count: open_reminders,
});
// Harness-only OTEL metrics (duration/wake_from/result_kind/loose-ends/
// session boundaries) — independent of the sqlite sink below, and a
// cheap no-op when OTEL isn't configured. See `otel_turn_metrics`'s
// module doc for why token/cost/tool-count are deliberately not here.
// session boundaries/context fill) — independent of the sqlite sink
// below, and a cheap no-op when OTEL isn't configured. See
// `otel_turn_metrics`'s module doc for why token/tool-count are
// deliberately not here, and why cost is only ever an ACP agent's.
otel_turn_metrics::record(&row, fresh_session);
if let Some(ctx) = bus.last_ctx_usage() {
otel_turn_metrics::record_context(ctx.context_tokens(), context_window);
}
for cost in hive_runtime::Runtime::take_reported_cost(session) {
otel_turn_metrics::record_reported_cost(&row.model, &cost);
}
if let Some(stats) = stats {
stats.record(&row);
}

View file

@ -6,8 +6,8 @@
//! only the harness-only concepts: wall-clock turn duration as *this harness*
//! measures it (not Claude's own per-request latency), what woke the turn,
//! the harness's own outcome classification, the loose-ends backlog at turn
//! end, and session boundaries (fresh vs. `--continue`'d). Scoped this way
//! per the tracker discussion on the "emit agent stats as OTEL metrics" issue.
//! end, session boundaries (fresh vs. `--continue`'d), context-window fill,
//! and the cost an ACP agent reports — claude's runtime reports none.
//!
//! Synchronous instruments, not observable ones: unlike hive-c0re's
//! container-resource gauges (`hive-c0re/src/stats/otel_metrics.rs`), which
@ -62,6 +62,11 @@ struct Instruments {
/// `window`.
claude_usage_percent: Gauge<f64>,
claude_usage_resets_at: Gauge<u64>,
context_used: Gauge<u64>,
context_size: Gauge<u64>,
/// Keyed by `model` and `currency`: an ACP agent reports cost in a
/// currency of its choosing.
reported_cost: Counter<f64>,
}
/// Lazily built on the first call to [`record`]. `None` when OTEL isn't
@ -129,6 +134,30 @@ pub fn record_claude_usage(window: &str, percent: f64, resets_at: Option<u64>) {
}
}
/// Record the context window's fill at turn end: `used` tokens of `size`,
/// the two numbers the web UI's ctx% divides. No-op when OTEL isn't
/// configured, same as [`record`].
pub fn record_context(used: u64, size: u64) {
let Some(inst) = INSTRUMENTS.get_or_init(build).as_ref() else {
return;
};
inst.context_used.record(used, &[]);
inst.context_size.record(size, &[]);
}
/// Add what an ACP agent reported spending since the last call. No-op when
/// OTEL isn't configured, same as [`record`].
pub fn record_reported_cost(model: &str, cost: &hive_runtime::ReportedCost) {
let Some(inst) = INSTRUMENTS.get_or_init(build).as_ref() else {
return;
};
let attrs = [
KeyValue::new("model", model.to_owned()),
KeyValue::new("currency", cost.currency.clone()),
];
inst.reported_cost.add(cost.amount, &attrs);
}
fn build() -> Option<Instruments> {
if !enabled() {
tracing::debug!("otel turn-metrics: no endpoint configured, exporter disabled");
@ -161,6 +190,15 @@ fn build() -> Option<Instruments> {
.u64_gauge("hyperhive.agent.claude_usage.resets_at")
.with_unit("s")
.build(),
context_used: meter
.u64_gauge("hyperhive.agent.context.used")
.with_unit("{token}")
.build(),
context_size: meter
.u64_gauge("hyperhive.agent.context.size")
.with_unit("{token}")
.build(),
reported_cost: meter.f64_counter("hyperhive.agent.cost.usage").build(),
_provider: provider,
})
}