feat: session-metrics実装

This commit is contained in:
2026-05-03 15:10:43 +09:00
parent 702ed79517
commit 70c4f1930e
15 changed files with 982 additions and 28 deletions
+1
View File
@@ -28,6 +28,7 @@ libc = { workspace = true }
schemars = { workspace = true }
memory = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
[dev-dependencies]
dotenv = "0.15.0"
+50
View File
@@ -0,0 +1,50 @@
//! Sync buffer for `session_metrics::Metric` values queued from inside
//! Worker callbacks (which run synchronously and cannot themselves
//! perform `async` store writes).
//!
//! Pod drains this buffer in `persist_turn` and writes each metric via
//! `session_metrics::record_metric`, alongside the regular `LlmUsage`
//! entries.
use std::sync::Mutex;
use session_metrics::Metric;
pub(crate) struct MetricsTracker {
pending: Mutex<Vec<Metric>>,
}
impl MetricsTracker {
pub(crate) fn new() -> Self {
Self {
pending: Mutex::new(Vec::new()),
}
}
/// Queue a metric for the next `persist_turn` flush.
pub(crate) fn push(&self, metric: Metric) {
self.pending.lock().unwrap().push(metric);
}
/// Drain all queued metrics. Called by Pod after a run completes.
pub(crate) fn drain(&self) -> Vec<Metric> {
std::mem::take(&mut *self.pending.lock().unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_then_drain_returns_in_order_and_clears() {
let t = MetricsTracker::new();
t.push(Metric::now("a"));
t.push(Metric::now("b"));
let drained = t.drain();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].name, "a");
assert_eq!(drained[1].name, "b");
assert!(t.drain().is_empty());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod metrics_tracker;
pub(crate) mod prune;
pub(crate) mod state;
pub(crate) mod token_counter;
+47 -1
View File
@@ -5,10 +5,16 @@
//! 直後)。Worker は usage 履歴を知らないので、`min_savings` 判定に使う savings
//! の見積もりはコールバックで外部から注入する。このモジュールはそのコールバック
//! を組み立てて Worker に差し込むための `impl Pod` を提供する。
//!
//! 同じ経路で `PruneObserver` も install し、評価のたびに `prune.fire` /
//! `prune.skip` metric を `MetricsTracker` に積む。`Fired` 時は uuid を
//! `UsageTracker` にも stash しておき、後続の `LlmUsage` と組で
//! `prune.post_request` を吐けるようにする。
use llm_worker::Item;
use llm_worker::llm_client::client::LlmClient;
use llm_worker::prune::{PruneConfig, SavingsEstimator};
use llm_worker::prune::{PruneConfig, PruneDecision, PruneObserver, SavingsEstimator};
use session_metrics::Metric;
use session_store::Store;
use crate::Pod;
@@ -24,6 +30,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// Measurement-less estimates (before the first LLM call, or immediately
/// after a compact) return `0` from the estimator, which naturally
/// prevents the prune projection from firing until usage data exists.
///
/// Also installs a [`PruneObserver`] that pushes `prune.fire` /
/// `prune.skip` metrics into the shared [`MetricsTracker`]. On `Fired`
/// the observer additionally stashes a fresh correlation_id in
/// [`UsageTracker`] so the next `LlmUsage` can be paired with a
/// `prune.post_request` metric carrying the same id.
pub fn attach_prune(&mut self, config: PruneConfig) {
let usage = self.usage_history_handle();
let estimator: SavingsEstimator = Box::new(move |history: &[Item], indices| {
@@ -34,9 +46,43 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
_ => est.tokens,
}
});
let metrics = self.metrics_tracker_handle();
let usage_tracker = self.usage_tracker_handle();
let observer: PruneObserver = Box::new(move |eval| {
match &eval.decision {
PruneDecision::Fired { .. } => {
let correlation_id = uuid::Uuid::now_v7().to_string();
let mut metric = Metric::now("prune.fire")
.with_value(eval.estimated_savings as f64)
.with_correlation_id(&correlation_id)
.with_dimension("candidate_count", eval.candidate_count.to_string());
if let Some(border) = eval.border_turn {
metric = metric.with_dimension("border_turn", border.to_string());
}
metrics.push(metric);
usage_tracker.note_correlation_id(correlation_id);
}
PruneDecision::SkippedNoCandidates => {
metrics.push(
Metric::now("prune.skip").with_dimension("reason", "no_candidates"),
);
}
PruneDecision::SkippedBelowMinSavings => {
metrics.push(
Metric::now("prune.skip")
.with_dimension("reason", "below_min_savings")
.with_dimension("candidate_count", eval.candidate_count.to_string())
.with_value(eval.estimated_savings as f64),
);
}
}
});
let worker = self.worker_mut();
worker.set_prune_config(Some(config));
worker.set_savings_estimator(Some(estimator));
worker.set_prune_observer(Some(observer));
}
/// If the manifest has a `[compaction]` section, build a `PruneConfig`
+69 -19
View File
@@ -19,19 +19,35 @@ use std::sync::Mutex;
use llm_worker::UsageRecord;
use llm_worker::timeline::event::UsageEvent;
/// One drained measurement: the underlying `UsageRecord` plus an optional
/// `correlation_id` stamped by the prune projection (or any other future
/// upstream observer) so that downstream metrics emitted alongside this
/// record can be joined to it after the fact.
#[derive(Debug, Clone)]
pub(crate) struct RecordedUsage {
pub(crate) record: UsageRecord,
pub(crate) correlation_id: Option<String>,
}
/// Shared between the pre-request hook, the `on_usage` callback, and Pod.
pub(crate) struct UsageTracker {
/// `history.len()` captured at the most recent `pre_llm_request`.
/// Cleared when paired with an incoming `on_usage` event.
pending_history_len: Mutex<Option<usize>>,
/// Optional `correlation_id` set by an upstream observer (currently
/// the prune projection on `Fired`). Paired into the next
/// `RecordedUsage` and cleared. Skips that don't fire leave this
/// `None`, so the resulting record carries no correlation.
pending_correlation_id: Mutex<Option<String>>,
/// Records accumulated during the current run; drained by Pod.
pending_records: Mutex<Vec<UsageRecord>>,
pending_records: Mutex<Vec<RecordedUsage>>,
}
impl UsageTracker {
pub(crate) fn new() -> Self {
Self {
pending_history_len: Mutex::new(None),
pending_correlation_id: Mutex::new(None),
pending_records: Mutex::new(Vec::new()),
}
}
@@ -41,16 +57,29 @@ impl UsageTracker {
*self.pending_history_len.lock().unwrap() = Some(history_len);
}
/// Stash a `correlation_id` to be paired into the next `RecordedUsage`.
/// Currently invoked by the prune observer on `Fired` so that the
/// `prune.fire` metric and the `prune.post_request` metric (emitted
/// alongside the resulting `LlmUsage`) carry the same join key.
///
/// Overwrites any previous unconsumed value — by construction the
/// observer fires at most once per outgoing LLM request, immediately
/// before the pre-request hook captures `history_len`.
pub(crate) fn note_correlation_id(&self, id: String) {
*self.pending_correlation_id.lock().unwrap() = Some(id);
}
/// Called from the `on_usage` callback with the aggregated final
/// UsageEvent. If a `history_len` was previously stashed via
/// `note_request`, builds a `UsageRecord` and pushes it onto the buffer.
/// If not (e.g. test code that fires Usage outside a request), drops
/// the event.
/// `note_request`, builds a `RecordedUsage` and pushes it onto the
/// buffer. If not (e.g. test code that fires Usage outside a request),
/// drops the event.
pub(crate) fn record_usage(&self, event: &UsageEvent) {
let history_len = match self.pending_history_len.lock().unwrap().take() {
Some(n) => n,
None => return,
};
let correlation_id = self.pending_correlation_id.lock().unwrap().take();
// UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に
// 正規化済みである前提(Anthropic は cache_read + cache_creation を
// 加算して emit する)。
@@ -58,18 +87,21 @@ impl UsageTracker {
let cache_read = event.cache_read_input_tokens.unwrap_or(0);
let cache_write = event.cache_creation_input_tokens.unwrap_or(0);
let output = event.output_tokens.unwrap_or(0);
self.pending_records.lock().unwrap().push(UsageRecord {
history_len,
input_total_tokens: input_total,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
output_tokens: output,
self.pending_records.lock().unwrap().push(RecordedUsage {
record: UsageRecord {
history_len,
input_total_tokens: input_total,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
output_tokens: output,
},
correlation_id,
});
}
/// Drain accumulated records. Called by Pod after a run completes,
/// before persisting the turn.
pub(crate) fn drain(&self) -> Vec<UsageRecord> {
pub(crate) fn drain(&self) -> Vec<RecordedUsage> {
std::mem::take(&mut *self.pending_records.lock().unwrap())
}
}
@@ -96,11 +128,12 @@ mod tests {
let records = tracker.drain();
assert_eq!(records.len(), 1);
assert_eq!(records[0].history_len, 5);
assert_eq!(records[0].input_total_tokens, 1000);
assert_eq!(records[0].cache_read_tokens, 800);
assert_eq!(records[0].cache_write_tokens, 100);
assert_eq!(records[0].output_tokens, 42);
assert_eq!(records[0].record.history_len, 5);
assert_eq!(records[0].record.input_total_tokens, 1000);
assert_eq!(records[0].record.cache_read_tokens, 800);
assert_eq!(records[0].record.cache_write_tokens, 100);
assert_eq!(records[0].record.output_tokens, 42);
assert!(records[0].correlation_id.is_none());
}
#[test]
@@ -129,8 +162,25 @@ mod tests {
let records = tracker.drain();
assert_eq!(records.len(), 2);
assert_eq!(records[0].history_len, 5);
assert_eq!(records[1].history_len, 10);
assert_eq!(records[1].cache_read_tokens, 50);
assert_eq!(records[0].record.history_len, 5);
assert_eq!(records[1].record.history_len, 10);
assert_eq!(records[1].record.cache_read_tokens, 50);
}
#[test]
fn correlation_id_pairs_with_next_record_only() {
let tracker = UsageTracker::new();
// Stash an ID, then run a request → the ID should land on this record.
tracker.note_correlation_id("abc".into());
tracker.note_request(5);
tracker.record_usage(&make_event(100, 0, 0, 20));
// Next request without a fresh stash → no correlation_id.
tracker.note_request(10);
tracker.record_usage(&make_event(200, 50, 0, 30));
let records = tracker.drain();
assert_eq!(records.len(), 2);
assert_eq!(records[0].correlation_id.as_deref(), Some("abc"));
assert!(records[1].correlation_id.is_none());
}
}
+68 -2
View File
@@ -77,6 +77,11 @@ pub struct Pod<C: LlmClient, St: Store> {
/// Captures `(history_len, UsageEvent)` pairs during a run; drained
/// in `persist_turn` and persisted as `LogEntry::LlmUsage` entries.
usage_tracker: Arc<UsageTracker>,
/// Sync-side buffer for `Metric` values queued from inside Worker
/// callbacks (currently the prune observer). Drained in `persist_turn`
/// and written via `session_metrics::record_metric` alongside
/// `LogEntry::LlmUsage`. Always present after construction.
metrics_tracker: Arc<crate::compact::metrics_tracker::MetricsTracker>,
/// Cumulative Usage measurement timeline, one entry per LLM call.
/// Restored from session log on `restore`, appended on each persist.
/// Read by token-accounting APIs (`Pod::total_tokens`, etc.).
@@ -203,6 +208,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None,
system_prompt_template: None,
@@ -391,6 +397,26 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.usage_history.clone()
}
/// Handle to the per-LLM-request `UsageTracker`.
///
/// Sibling modules (e.g. the prune observer) clone this `Arc` to stash
/// per-request side state (e.g. a `correlation_id`) that pairs with
/// the next `LlmUsage`.
pub(crate) fn usage_tracker_handle(&self) -> Arc<UsageTracker> {
self.usage_tracker.clone()
}
/// Handle to the synchronous `MetricsTracker` buffer.
///
/// Worker callbacks (e.g. the prune observer) clone this `Arc` and
/// `.push(metric)` into it; Pod drains it in `persist_turn` and
/// writes each metric via `session_metrics::record_metric`.
pub(crate) fn metrics_tracker_handle(
&self,
) -> Arc<crate::compact::metrics_tracker::MetricsTracker> {
self.metrics_tracker.clone()
}
/// Attach the session-scoped file-operation tracker from the builtin
/// `tools` crate. Called by the Controller immediately after it
/// registers the builtin tools on the Worker. Overwrites any
@@ -1068,13 +1094,36 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
)
.await?;
// Flush any sync-buffered metrics from this run first
// (currently `prune.fire` / `prune.skip` from the prune observer).
// Ordered before LlmUsage so that a `prune.fire` and the
// `prune.post_request` derived from the matching usage record
// appear in the log close together.
let pending_metrics = self.metrics_tracker.drain();
for metric in pending_metrics {
session_metrics::record_metric(
&self.store,
self.session_id,
&mut self.head_hash,
&metric,
)
.await?;
}
// Persist any LLM Usage measurements collected during this run.
// One LogEntry::LlmUsage per LLM call (the tool loop may have run
// many calls within a single Pod::run). Each is also appended to
// the in-memory `usage_history` so token-accounting APIs see it
// before the next run.
// before the next run. Records carrying a `correlation_id` (set
// by an upstream observer such as the prune projection) also get
// a paired `prune.post_request` metric so cache_read/write can be
// joined back to the originating event.
let usage_records = self.usage_tracker.drain();
for record in usage_records {
for recorded in usage_records {
let crate::compact::usage_tracker::RecordedUsage {
record,
correlation_id,
} = recorded;
session_store::save_usage(
&self.store,
self.session_id,
@@ -1086,6 +1135,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
record.output_tokens,
)
.await?;
if let Some(id) = correlation_id {
let metric = session_metrics::Metric::now("prune.post_request")
.with_correlation_id(&id)
.with_value(record.cache_read_tokens as f64)
.with_dimension("cache_write_tokens", record.cache_write_tokens.to_string())
.with_dimension("history_len", record.history_len.to_string());
session_metrics::record_metric(
&self.store,
self.session_id,
&mut self.head_hash,
&metric,
)
.await?;
}
self.usage_history
.lock()
.expect("usage_history poisoned")
@@ -1895,6 +1958,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
system_prompt_template: common.system_prompt_template,
@@ -1956,6 +2020,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
system_prompt_template: common.system_prompt_template,
@@ -2067,6 +2132,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
interceptor_installed: false,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
// Restore replays the saved system_prompt verbatim — no
+367
View File
@@ -0,0 +1,367 @@
//! End-to-end coverage for the prune-projection metrics path.
//!
//! Drives a Pod with a scripted mock LLM client and a custom tool that
//! returns a long `ToolOutput.content`, then inspects the persisted
//! session log to verify:
//!
//! - `prune.skip { reason: "no_candidates" }` lands when the protected-turn
//! window covers the entire history.
//! - `prune.fire` lands once enough turns + usage measurements exist for
//! the projection to actually apply.
//! - The fire metric and the immediately-following `prune.post_request`
//! metric share the same `correlation_id`, so cache_read / cache_write
//! from the LlmUsage that triggered the projection can be joined back
//! to the originating event.
//! - `prune.skip { reason: "below_min_savings" }` lands when candidates
//! exist but their estimated savings are below the configured floor.
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{
Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent,
};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use session_metrics::{DOMAIN, Metric, metrics_from_extensions};
use session_store::FsStore;
use pod::{Pod, PodManifest};
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
call_count: Arc<AtomicUsize>,
}
impl MockClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl LlmClient for MockClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count >= self.responses.len() {
return Err(ClientError::Config("mock client exhausted".into()));
}
let events = self.responses[count].clone();
let stream = futures::stream::iter(events.into_iter().map(Ok));
Ok(Box::pin(stream))
}
}
/// Tool that returns a fixed `ToolOutput { summary, content: Some(big) }`.
/// `content` is long enough for prune savings to comfortably clear small
/// `min_savings` thresholds.
struct BigContentTool {
summary: &'static str,
content: String,
}
#[async_trait]
impl Tool for BigContentTool {
async fn execute(&self, _input: &str) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
summary: self.summary.into(),
content: Some(self.content.clone()),
})
}
}
fn big_content_tool_definition(name: &'static str) -> ToolDefinition {
Arc::new(move || {
let summary = "tool result summary";
let content = "x".repeat(2048);
(
ToolMeta::new(name)
.description("test tool that returns a long content")
.input_schema(serde_json::json!({"type": "object"})),
Arc::new(BigContentTool { summary, content }) as Arc<dyn Tool>,
)
})
}
fn usage_event(input_total: u64, cache_read: u64, cache_write: u64, output: u64) -> LlmEvent {
LlmEvent::Usage(UsageEvent {
input_tokens: Some(input_total),
output_tokens: Some(output),
total_tokens: Some(input_total + output),
cache_read_input_tokens: Some(cache_read),
cache_creation_input_tokens: Some(cache_write),
})
}
/// Tool-call response from the assistant: emits a `tool_use` block then a
/// usage event so usage_history gains a measurement on this turn.
fn tool_use_response(call_id: &str, tool_name: &str) -> Vec<LlmEvent> {
vec![
LlmEvent::tool_use_start(0, call_id, tool_name),
LlmEvent::tool_input_delta(0, "{}"),
LlmEvent::tool_use_stop(0),
usage_event(500, 0, 0, 10),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
/// Plain text response with explicit cache_read/cache_write so that
/// `prune.post_request` can carry meaningful values when this is the
/// LLM call that follows a `prune.fire` event.
fn text_response_with_cache(text: &str, cache_read: u64, cache_write: u64) -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, text),
LlmEvent::text_block_stop(0, None),
usage_event(800, cache_read, cache_write, 5),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
fn manifest_toml(prune_protected_turns: usize, prune_min_savings: u64) -> String {
format!(
r#"
[pod]
name = "test-pod"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
max_tokens = 100
[compaction]
prune_protected_turns = {prune_protected_turns}
prune_min_savings = {prune_min_savings}
[[scope.allow]]
target = "./"
permission = "write"
"#
)
}
async fn make_pod(
manifest_toml: String,
client: MockClient,
tool_name: &'static str,
) -> (Pod<MockClient, FsStore>, tempfile::TempDir, tempfile::TempDir) {
let manifest = PodManifest::from_toml(&manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = pod::Scope::writable(&pwd).unwrap();
let mut worker = Worker::new(client);
worker.register_tool(big_content_tool_definition(tool_name));
let pod = Pod::new(manifest, worker, store, pwd, scope).await.unwrap();
(pod, store_tmp, pwd_tmp)
}
/// Drive Pod through enough runs to exercise both skip-no_candidates and
/// fire branches, then read the session log back and assert the metric
/// stream.
#[tokio::test]
async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
// Run 1 (request 0): tool_use → triggers tool execution → request 1
// on the second iteration to produce the assistant reply.
// Run 2 (request 2): plain assistant text. Prune evaluation here
// sees user1's tool_result outside the 1-protected-turn window and
// should fire.
let client = MockClient::new(vec![
tool_use_response("call-1", "big_tool"),
text_response_with_cache("ok", 0, 200),
text_response_with_cache("done", 1234, 50),
]);
let (mut pod, _store_tmp, _pwd_tmp) =
make_pod(manifest_toml(1, 1), client, "big_tool").await;
let session_id = pod.session_id();
// Cloning the store handle to read the session log back after the
// runs complete — the Pod retains its own copy.
let store = pod.store().clone();
pod.run_text("first").await.unwrap();
pod.run_text("second").await.unwrap();
let state = session_store::restore(&store, session_id).await.unwrap();
let metrics = metrics_from_extensions(&state.extensions);
// Run 1 has 2 LLM iterations (tool loop), each evaluates prune with
// only one user-message turn → 2x skip{no_candidates}.
// Run 2 has 1 LLM iteration with enough turns → 1x fire +
// 1x post_request paired by correlation_id.
let names: Vec<&str> = metrics.iter().map(|m| m.name.as_str()).collect();
assert!(
names.contains(&"prune.skip"),
"expected prune.skip in {names:?}"
);
assert!(
names.contains(&"prune.fire"),
"expected prune.fire in {names:?}"
);
assert!(
names.contains(&"prune.post_request"),
"expected prune.post_request in {names:?}"
);
// All skips in run 1 must record reason=no_candidates.
for m in metrics.iter().filter(|m| m.name == "prune.skip") {
assert_eq!(
m.dimensions.get("reason").map(String::as_str),
Some("no_candidates"),
"skip metric should be no_candidates here, got {m:?}"
);
assert!(m.correlation_id.is_none());
}
// The fire metric carries dimensions and correlation_id.
let fire = metrics
.iter()
.find(|m| m.name == "prune.fire")
.expect("prune.fire missing");
assert!(
fire.dimensions.contains_key("candidate_count"),
"fire missing candidate_count: {fire:?}"
);
assert!(
fire.dimensions.contains_key("border_turn"),
"fire missing border_turn: {fire:?}"
);
assert!(
fire.value.is_some(),
"fire missing estimated_savings value"
);
let fire_id = fire
.correlation_id
.as_ref()
.expect("fire metric missing correlation_id");
// Exactly one post_request metric should exist with the same id, and
// its value/dimension should reflect the cache numbers from the
// text_response_with_cache call (cache_read=1234, cache_write=50).
let post = metrics
.iter()
.find(|m| m.name == "prune.post_request")
.expect("prune.post_request missing");
assert_eq!(post.correlation_id.as_ref(), Some(fire_id));
assert_eq!(post.value, Some(1234.0));
assert_eq!(
post.dimensions.get("cache_write_tokens").map(String::as_str),
Some("50")
);
assert!(post.dimensions.contains_key("history_len"));
}
/// `min_savings` set high enough that candidates exist but the estimated
/// savings always fall short → the second run should record
/// `prune.skip { reason: "below_min_savings" }`.
#[tokio::test]
async fn prune_metrics_record_below_min_savings_skip() {
let client = MockClient::new(vec![
tool_use_response("call-1", "big_tool"),
text_response_with_cache("ok", 0, 100),
text_response_with_cache("done", 0, 0),
]);
let (mut pod, _store_tmp, _pwd_tmp) =
make_pod(manifest_toml(1, u64::MAX), client, "big_tool").await;
let session_id = pod.session_id();
let store = pod.store().clone();
pod.run_text("first").await.unwrap();
pod.run_text("second").await.unwrap();
let state = session_store::restore(&store, session_id).await.unwrap();
let metrics = metrics_from_extensions(&state.extensions);
let below = metrics
.iter()
.find(|m| {
m.name == "prune.skip"
&& m.dimensions.get("reason").map(String::as_str) == Some("below_min_savings")
})
.expect("expected prune.skip with reason=below_min_savings");
assert!(
below.dimensions.contains_key("candidate_count"),
"below_min_savings skip should report candidate_count: {below:?}"
);
assert!(
below.value.is_some(),
"below_min_savings skip should report estimated savings as value: {below:?}"
);
// No prune.fire for this scenario.
assert!(metrics.iter().all(|m| m.name != "prune.fire"));
// No prune.post_request either (no fire to join with).
assert!(metrics.iter().all(|m| m.name != "prune.post_request"));
}
/// Sessions that have no metrics in the log restore cleanly: the
/// `RestoredState.extensions` simply contains no `metrics` domain
/// payloads, and `metrics_from_extensions` returns an empty Vec.
/// Backward-compatibility check for old logs predating this feature.
#[tokio::test]
async fn old_sessions_without_metrics_replay_cleanly() {
// Manifest without any `[compaction]` section → prune (and therefore
// the prune observer) is never installed, so no metrics get written.
let manifest_toml = r#"
[pod]
name = "test-pod"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[worker]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "write"
"#;
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let manifest = PodManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = pod::Scope::writable(&pwd).unwrap();
let worker = Worker::new(client);
let mut pod = Pod::new(manifest, worker, store.clone(), pwd, scope)
.await
.unwrap();
let session_id = pod.session_id();
pod.run_text("hello").await.unwrap();
let state = session_store::restore(&store, session_id).await.unwrap();
let metrics = metrics_from_extensions(&state.extensions);
assert!(metrics.is_empty(), "no metrics should be recorded: {metrics:?}");
// And no extension entries at all in the metrics domain.
assert!(state.extensions.iter().all(|(d, _)| d != DOMAIN));
// Smoke check that fold helper is robust on a sentinel Metric value:
let m = Metric::now("smoke");
assert_eq!(m.name, "smoke");
}