Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

- `burn summary` and `burn compare` accept `--bucket <DURATION>` to emit a per-bucket time-series across the `--since` window instead of a single total (`{ "bucketSeconds": N, "buckets": [...] }` in JSON). Bucket units: `30s` / `5m` / `1h` / `12h` / `1d` / `7d` — note `m` is minutes here, unlike `--since` where `m` is months. `summary --bucket` supports only the default grouped (`byModel` / `--by-provider`) modes; `hotspots` / `overhead` are unchanged.

## [3.3.0] - 2026-06-17

- `burn summary` and `burn hotspots` no longer run a pre-query ingest sweep by default — both return in well under 100ms instead of seconds on large ledgers (`hotspots` was ~3s). Pass `--ingest` for a one-off freshen; keep the ledger current out of band with `burn ingest --watch` or the Claude Stop hook.
Expand Down
7 changes: 7 additions & 0 deletions crates/relayburn-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,13 @@ pub struct CompareArgs {
/// which is SQLite-native and has no archive layer to bypass.
#[arg(long = "no-archive")]
pub no_archive: bool,

/// Emit a per-bucket time-series across the `--since` window instead of a
/// single comparison. Duration grammar: `30s`, `5m` (minutes), `1h`, `1d`,
/// `7d`. Note: small buckets thin each cell's sample, so `insufficientSample`
/// trips more often.
#[arg(long, value_name = "DURATION")]
pub bucket: Option<String>,
}

/// `burn overhead [trim]` argument set. The top-level form takes the
Expand Down
61 changes: 61 additions & 0 deletions crates/relayburn-cli/src/commands/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
));
}

// `--bucket` opts into a per-bucket time-series. Parse it up front (before
// the ledger is opened) so a bad duration routes through the same error
// envelope as the other argument validations. CSV time-series rendering
// isn't implemented, so reject the combination rather than silently
// falling back to TTY output.
let bucket_secs = args
.bucket
.as_deref()
.map(relayburn_sdk::parse_bucket)
.transpose()?;
if bucket_secs.is_some() && args.csv {
return Err(anyhow!(
"--bucket and --csv are mutually exclusive; pick one."
));
}

// 4. Provider filter. Lower-cased CSV; turns whose effective provider
// (per `provider_for`) isn't in the set get dropped after the ledger
// query but before the fidelity gate, matching the TS pipeline order.
Expand Down Expand Up @@ -180,6 +196,51 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
};
progress.set_task("opening ledger");
let handle = Ledger::open(ledger_opts)?;

// `--bucket` switches to a per-bucket time-series via the SDK verb.
// Parsing/validation already happened above, before the ledger was opened.
if let Some(bucket_secs) = bucket_secs {
let provider = provider_filter
.as_ref()
.map(|f| f.iter().cloned().collect::<Vec<_>>());
progress.set_task("building comparison time-series");
let series = handle
.compare_timeseries(
relayburn_sdk::CompareOptions {
models: models.clone(),
session: args.session.clone(),
project: args.project.clone(),
since: args.since.clone(),
workflow: args.workflow.clone(),
agent: args.agent.clone(),
provider,
min_sample: Some(min_sample),
min_fidelity: Some(min_fidelity),
ledger_home: None,
},
bucket_secs,
)
.inspect_err(|_| progress.finish_and_clear())?;
progress.finish_and_clear();
if globals.json {
render_json(&series)?;
return Ok(0);
}
if series.buckets.is_empty() {
println!("(no data in range)");
return Ok(0);
}
for bucket in &series.buckets {
println!(
"{} {:>5} turns {} models",
bucket.start,
bucket.result.analyzed_turns,
bucket.result.models.len(),
);
}
return Ok(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

progress.set_task("loading turns");
let queried_turns: Vec<EnrichedTurn> = handle.raw().query_turns(&q)?;

Expand Down
120 changes: 99 additions & 21 deletions crates/relayburn-cli/src/commands/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ pub struct SummaryArgs {
/// `--ingest` only for a one-off freshen.
#[arg(long = "ingest")]
pub ingest: bool,

/// Emit a time-series instead of a single total: bucket the `--since`
/// window into fixed-width windows and report per-bucket cost/usage.
/// Duration grammar: `30s`, `5m` (minutes), `1h`, `12h`, `1d`, `7d`.
/// Only valid for the default grouped (`byModel`/`--by-provider`) summary.
#[arg(long, value_name = "DURATION")]
pub bucket: Option<String>,
}

pub fn run(globals: &GlobalArgs, args: SummaryArgs) -> i32 {
Expand Down Expand Up @@ -195,6 +202,36 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
}
}

// `--bucket` opts into a per-bucket time-series. Parse and validate it
// (including the mode/flag combinations it supports) before opening the
// ledger or running ingest, so a bad invocation fails fast.
let bucket_secs = if let Some(bucket_raw) = args.bucket.as_deref() {
if args.by_tool
|| args.by_subagent_type
|| args.by_relationship.is_some()
|| args.subagent_tree.is_some()
|| args.group_by_tag.is_some()
{
eprintln!(
"burn: --bucket is only supported with the default grouped summary or --by-provider"
);
return Ok(2);
}
if args.quality {
eprintln!("burn: --bucket is not supported with --quality");
return Ok(2);
}
match relayburn_sdk::parse_bucket(bucket_raw) {
Ok(secs) => Some(secs),
Err(err) => {
eprintln!("burn: {err}");
return Ok(2);
}
}
} else {
None
};

let provider_filter = match parse_provider_filter(args.provider.as_deref()) {
Ok(filter) => filter,
Err(msg) => {
Expand Down Expand Up @@ -265,28 +302,41 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
}
};

let opts = SummaryReportOptions {
session: args.session,
project: args.project,
since: args.since,
workflow: args.workflow,
tags: if tag_filter.is_empty() {
None
} else {
Some(tag_filter)
},
group_by_tag: args.group_by_tag,
agent: args.agent,
providers: provider_filter.map(|providers| providers.into_iter().collect()),
mode,
include_quality: args.quality,
ledger_home: None,
};

// `--bucket` switches to a per-bucket time-series of the grouped summary.
// Parsing/validation already happened above, before the ledger was opened.
if let Some(bucket_secs) = bucket_secs {
progress.set_task("building summary time-series");
let series = handle
.summary_timeseries(opts, bucket_secs)
.inspect_err(|_| {
progress.finish_and_clear();
})?;
progress.finish_and_clear();
return emit_summary_timeseries(globals, &series, &ingest_report);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

progress.set_task("building summary");
let report = handle
.summary_report(SummaryReportOptions {
session: args.session,
project: args.project,
since: args.since,
workflow: args.workflow,
tags: if tag_filter.is_empty() {
None
} else {
Some(tag_filter)
},
group_by_tag: args.group_by_tag,
agent: args.agent,
providers: provider_filter.map(|providers| providers.into_iter().collect()),
mode,
include_quality: args.quality,
ledger_home: None,
})
.inspect_err(|_| {
progress.finish_and_clear();
})?;
let report = handle.summary_report(opts).inspect_err(|_| {
progress.finish_and_clear();
})?;
progress.finish_and_clear();

match report {
Expand Down Expand Up @@ -444,6 +494,34 @@ fn emit_json(
render_json(&value)
}

/// Render a `--bucket` time-series. JSON emits `{ bucketSeconds, buckets: [...] }`
/// (the consumer); human output is one line per bucket.
fn emit_summary_timeseries(
globals: &GlobalArgs,
series: &relayburn_sdk::SummaryTimeseries,
ingest_report: &relayburn_sdk::IngestReport,
) -> anyhow::Result<i32> {
if globals.json {
render_json(series)?;
return Ok(0);
}
emit_human_ingest_prelude(ingest_report);
if series.buckets.is_empty() {
println!("(no data in range)");
return Ok(0);
}
for bucket in &series.buckets {
println!(
"{} {:>5} turns {:>14} tok {}",
bucket.start,
bucket.turn_count,
format_uint(bucket.total_tokens),
format_usd(bucket.total_cost.total),
);
}
Ok(0)
}

fn grouped_json_value(
report: &SummaryGroupedReport,
ingest_report: &relayburn_sdk::IngestReport,
Expand Down
125 changes: 125 additions & 0 deletions crates/relayburn-sdk/src/query_verbs/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,131 @@ impl LedgerHandle {
fidelity_summary,
))
}

/// Time-bucketed [`compare`]: the same model comparison computed per
/// `bucket_secs`-wide window across the `--since` range (turn-level
/// partition; a clean per-turn fold). Note: small buckets thin each
/// model×category cell's sample, so `insufficientSample` trips far more
/// often than over the whole window.
pub fn compare_timeseries(
&self,
opts: CompareOptions,
bucket_secs: u64,
) -> Result<CompareTimeseries> {
if opts.models.len() < 2 {
anyhow::bail!("compare: needs at least 2 models");
}
let min_fidelity = opts.min_fidelity.unwrap_or(FidelityClass::UsageOnly);
let models = opts.models.clone();
let min_sample = opts.min_sample;

let mut q = build_query(
opts.session.as_deref(),
opts.project.as_deref(),
opts.since.as_deref(),
)?;
let mut enrichment = BTreeMap::new();
if let Some(workflow) = opts.workflow {
enrichment.insert("workflowId".to_string(), workflow);
}
if let Some(agent) = opts.agent {
enrichment.insert("agentId".to_string(), agent);
}
if !enrichment.is_empty() {
q.enrichment = Some(enrichment);
}

let mut turns = self.inner.query_turns(&q)?;
if let Some(filter) = normalize_provider_filter(opts.provider) {
turns.retain(|t| {
let provider = crate::analyze::provider_for(&t.turn).provider;
filter.contains(&provider.to_ascii_lowercase())
});
}
let pricing = load_pricing(None);

let Some(anchor) = super::bucket_anchor_secs(
q.since.as_deref(),
turns
.iter()
.filter_map(|t| super::iso_z_to_epoch_secs(&t.turn.ts)),
) else {
return Ok(CompareTimeseries {
bucket_secs,
buckets: Vec::new(),
});
};
let now = super::system_now_secs() as i64;
super::ensure_bucket_span(anchor, now, bucket_secs)?;
let buckets = super::Buckets::new(anchor, now, bucket_secs);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let n = buckets.len();

let mut per_bucket: Vec<Vec<EnrichedTurn>> = (0..n).map(|_| Vec::new()).collect();
for t in turns {
let Some(ep) = super::iso_z_to_epoch_secs(&t.turn.ts) else {
continue;
};
if let Some(i) = buckets.index_for(ep) {
per_bucket[i].push(t);
}
}

let out = per_bucket
.into_iter()
.enumerate()
.map(|(i, mut bturns)| {
let fidelity_summary =
summarize_fidelity_from_iter(bturns.iter().map(|t| t.turn.fidelity.as_ref()));
if min_fidelity != FidelityClass::Partial {
bturns.retain(|t| has_minimum_fidelity(t.turn.fidelity.as_ref(), min_fidelity));
}
let table = build_compare_table(
&bturns,
&AnalyzeCompareOptions {
pricing: &pricing,
models: Some(models.clone()),
min_sample,
},
);
let result = shape_compare_result(
table,
bturns.len() as u64,
min_fidelity,
fidelity_summary,
);
CompareBucket {
start: buckets.start_iso(i),
end: buckets.end_iso(i),
result,
}
})
.collect();

Ok(CompareTimeseries {
bucket_secs,
buckets: out,
})
}
}

/// One time bucket of a [`CompareTimeseries`] — the [`CompareResult`] for turns
/// in `[start, end)`, flattened alongside the window bounds.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompareBucket {
pub start: String,
pub end: String,
#[serde(flatten)]
pub result: CompareResult,
}

/// A time-series of model comparisons, one [`CompareBucket`] per window.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompareTimeseries {
#[serde(rename = "bucketSeconds")]
pub bucket_secs: u64,
pub buckets: Vec<CompareBucket>,
}

pub fn compare(opts: CompareOptions) -> Result<CompareResult> {
Expand Down
Loading
Loading