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:
parent
5d7042e655
commit
c2bdf30e05
8 changed files with 605 additions and 26 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue