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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

### Added

- `burn hotspots`: new MCP-server rollup that collapses every
`mcp__<server>__<tool>` tool call into one row per server, so a chatty
MCP server (e.g. relaycast) shows up as a single line instead of 50+.
Surfaced in the human renderer (when non-empty) and as `mcpServers`
in `--json`. (#424)
Comment on lines +9 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Drop the PR/issue marker from the root Unreleased entry.

The impact text is good; just remove (#424) to match changelog policy.

Suggested edit
 - `burn hotspots`: new MCP-server rollup that collapses every
   `mcp__<server>__<tool>` tool call into one row per server, so a chatty
   MCP server (e.g. relaycast) shows up as a single line instead of 50+.
   Surfaced in the human renderer (when non-empty) and as `mcpServers`
-  in `--json`. (`#424`)
+  in `--json`.

As per coding guidelines, changelog entries should “Drop issue/PR links, internal review notes, implementation backstory…”.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `burn hotspots`: new MCP-server rollup that collapses every
`mcp__<server>__<tool>` tool call into one row per server, so a chatty
MCP server (e.g. relaycast) shows up as a single line instead of 50+.
Surfaced in the human renderer (when non-empty) and as `mcpServers`
in `--json`. (#424)
- `burn hotspots`: new MCP-server rollup that collapses every
`mcp__<server>__<tool>` tool call into one row per server, so a chatty
MCP server (e.g. relaycast) shows up as a single line instead of 50+.
Surfaced in the human renderer (when non-empty) and as `mcpServers`
in `--json`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 9 - 13, The Unreleased changelog entry ending with
"(`#424`)" should drop the PR/issue marker: edit the CHANGELOG.md entry for "burn
hotspots" (the Unreleased root) and remove the trailing " (`#424`)" so the line
ends after "`mcpServers` in `--json`." and no PR/issue/reference remains; keep
the rest of the text unchanged.


## [2.9.0] - 2026-05-21

### Added
Expand Down
58 changes: 56 additions & 2 deletions crates/relayburn-cli/src/commands/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use relayburn_sdk::{
hotspots as sdk_hotspots, ingest_all, AttributionMethod, BashAggregation, BashVerbAggregation,
FileAggregation, HotspotsAttributionResult, HotspotsExcludedBreakdown,
HotspotsExcludedSourceRow, HotspotsGroupBy, HotspotsOptions, HotspotsResult,
HotspotsSessionTotal, Ledger, LedgerOpenOptions, SubagentAggregation, WasteFinding,
WasteSeverity,
HotspotsSessionTotal, Ledger, LedgerOpenOptions, McpServerAggregation, SubagentAggregation,
WasteFinding, WasteSeverity,
};
use serde_json::{json, Map, Value};

Expand Down Expand Up @@ -339,6 +339,10 @@ fn attribution_to_json(a: &HotspotsAttributionResult) -> Value {
"subagents".into(),
Value::Array(a.subagents.iter().map(subagent_to_json).collect()),
);
out.insert(
"mcpServers".into(),
Value::Array(a.mcp_servers.iter().map(mcp_server_to_json).collect()),
);
out.insert(
"fidelity".into(),
json!({
Expand Down Expand Up @@ -501,6 +505,18 @@ fn subagent_to_json(s: &SubagentAggregation) -> Value {
})
}

fn mcp_server_to_json(m: &McpServerAggregation) -> Value {
json!({
"server": m.server,
"callCount": m.call_count,
"initialTokens": m.initial_tokens,
"persistenceTokens": m.persistence_tokens,
"ridingTurns": m.riding_turns,
"totalCost": m.total_cost,
"topTools": m.top_tools,
})
}

// ---------- human rendering ----------

fn emit_human(result: &HotspotsResult, limit: usize, findings_view: bool) {
Expand Down Expand Up @@ -871,6 +887,25 @@ fn emit_human_attribution(a: &HotspotsAttributionResult, limit: usize) {
}
out.push(String::new());

if !a.mcp_servers.is_empty() {
out.push(format!("Top MCP servers by cost{}", approx_suffix));
let header: Vec<String> = vec![
"server".into(),
"calls".into(),
"initial(tok)".into(),
"persist(tok)".into(),
"rideTurns".into(),
"cost".into(),
"topTools".into(),
];
let mut rows: Vec<Vec<String>> = vec![header];
for m in a.mcp_servers.iter().take(limit) {
rows.push(mcp_server_row(m));
}
out.push(render_table(&rows));
out.push(String::new());
}

print!("{}", out.join("\n"));
}

Expand Down Expand Up @@ -991,6 +1026,25 @@ fn subagent_row(s: &SubagentAggregation) -> Vec<String> {
]
}

fn mcp_server_row(m: &McpServerAggregation) -> Vec<String> {
vec![
m.server.clone(),
format_uint(m.call_count),
format_uint(m.initial_tokens.round() as u64),
format_uint(m.persistence_tokens.round() as u64),
format_uint(m.riding_turns),
format_usd(m.total_cost),
truncate(
&m.top_tools
.iter()
.map(|t| truncate(t, 40))
.collect::<Vec<_>>()
.join("; "),
90,
),
]
}

fn truncate(s: &str, n: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= n {
Expand Down
7 changes: 4 additions & 3 deletions crates/relayburn-sdk/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ pub use ghost_surface_inputs::{
pick_representative_cache_read_rate,
};
pub use hotspots::{
aggregate_by_bash, aggregate_by_bash_verb, aggregate_by_file, aggregate_by_subagent,
attribute_hotspots, AttributionMethod, BashAggregation, BashVerbAggregation, FileAggregation,
HotspotsOptions, HotspotsResult, SessionTotals, SubagentAggregation, ToolAttribution,
aggregate_by_bash, aggregate_by_bash_verb, aggregate_by_file, aggregate_by_mcp_server,
aggregate_by_subagent, attribute_hotspots, AttributionMethod, BashAggregation,
BashVerbAggregation, FileAggregation, HotspotsOptions, HotspotsResult, McpServerAggregation,
SessionTotals, SubagentAggregation, ToolAttribution,
};
pub use pricing::{
flatten_value, load_builtin_pricing, load_pricing, ModelCost, PricingTable, ReasoningMode,
Expand Down
179 changes: 179 additions & 0 deletions crates/relayburn-sdk/src/analyze/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ pub struct SubagentAggregation {
pub persistence_tokens: f64,
}

/// MCP-server rollup: groups any `mcp__<server>__<tool>` tool attribution by
/// `<server>` so a chatty MCP server (50+ distinct tools, none individually
/// expensive) shows up as a single row. `top_tools` carries up to three
/// representative tool basenames (cost desc, then name asc). Sorted by
/// `total_cost` descending.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerAggregation {
pub server: String,
pub call_count: u64,
pub initial_tokens: f64,
pub persistence_tokens: f64,
pub riding_turns: u64,
pub total_cost: f64,
pub top_tools: Vec<String>,
}

static FILE_TOOLS: phf::Set<&'static str> = phf_set! {
"Read", "Edit", "Write", "NotebookEdit",
};
Expand Down Expand Up @@ -777,6 +794,21 @@ where
out
}

/// Split an `mcp__<server>__<tool>` tool name into `(server, tool)`. Returns
/// `None` for any name that doesn't carry the `mcp__` prefix, has no server /
/// tool separator, or has an empty server or tool segment. Tool basenames may
/// themselves contain underscores; only the *first* `__` after the `mcp__`
/// prefix separates server from tool.
fn parse_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
let rest = name.strip_prefix("mcp__")?;
let sep = rest.find("__")?;
let (server, tool) = (&rest[..sep], &rest[sep + 2..]);
if server.is_empty() || tool.is_empty() {
return None;
}
Some((server, tool))
}

/// Roll up `Agent` / `Task` spawn attributions by `subagent_type`. Spawns
/// without a resolved type bucket under `"(unknown)"`. Output is sorted by
/// `total_cost` descending.
Expand Down Expand Up @@ -810,6 +842,74 @@ pub fn aggregate_by_subagent(attributions: &[ToolAttribution]) -> Vec<SubagentAg
)
}

struct McpServerAccumulator {
server: String,
call_count: u64,
total_cost: f64,
initial_tokens: f64,
persistence_tokens: f64,
riding_turns: u64,
/// `tool basename -> (cost, first-seen-order via IndexMap)`. Insertion
/// order is the example-sort tiebreaker before we sort by cost desc /
/// name asc.
tools: IndexMap<String, f64>,
}

/// Roll up any `mcp__<server>__<tool>` tool attribution by its server
/// segment so a chatty MCP server collapses into a single row. Non-MCP
/// tools (and malformed `mcp__…` names that fail to split into a
/// non-empty server + tool) are skipped. Output is sorted by `total_cost`
/// desc, then `server` asc as a stable tiebreaker.
pub fn aggregate_by_mcp_server(attributions: &[ToolAttribution]) -> Vec<McpServerAggregation> {
let mut by_server: IndexMap<String, McpServerAccumulator> = IndexMap::new();
for a in attributions {
let Some((server, tool)) = parse_mcp_tool_name(&a.tool_name) else {
continue;
};
let row = by_server
.entry(server.to_string())
.or_insert_with(|| McpServerAccumulator {
server: server.to_string(),
call_count: 0,
total_cost: 0.0,
initial_tokens: 0.0,
persistence_tokens: 0.0,
riding_turns: 0,
tools: IndexMap::new(),
});
row.call_count += 1;
row.total_cost += a.total_cost;
row.initial_tokens += a.initial_tokens;
row.persistence_tokens += a.persistence_tokens;
row.riding_turns += a.riding_turns;
*row.tools.entry(tool.to_string()).or_insert(0.0) += a.total_cost;
}

let mut out: Vec<McpServerAggregation> = by_server
.into_values()
.map(|row| {
let mut tools: Vec<(String, f64)> = row.tools.into_iter().collect();
tools.sort_by(|(an, ac), (bn, bc)| bc.total_cmp(ac).then_with(|| an.cmp(bn)));
let top_tools: Vec<String> = tools.into_iter().take(3).map(|(n, _)| n).collect();
McpServerAggregation {
server: row.server,
call_count: row.call_count,
initial_tokens: row.initial_tokens,
persistence_tokens: row.persistence_tokens,
riding_turns: row.riding_turns,
total_cost: row.total_cost,
top_tools,
}
})
.collect();
out.sort_by(|a, b| {
b.total_cost
.total_cmp(&a.total_cost)
.then_with(|| a.server.cmp(&b.server))
});
out
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1359,6 +1459,85 @@ mod tests {
assert!(subagents[0].total_cost > 0.0);
}

fn mcp_attribution(tool_name: &str, total_cost: f64, riding_turns: u64) -> ToolAttribution {
ToolAttribution {
tool_use_id: format!("tu-{tool_name}"),
tool_name: tool_name.into(),
target: None,
args_hash: format!("{tool_name}:0"),
session_id: "s-mcp".into(),
emit_turn_index: 0,
emit_ts: "2026-04-20T00:00:00.000Z".into(),
model: "claude-sonnet-4-6".into(),
project: None,
project_key: None,
subagent_type: None,
result_tokens: 0,
result_bytes_estimated: true,
initial_cost: total_cost,
initial_tokens: total_cost * 100.0,
persistence_cost: 0.0,
persistence_tokens: total_cost * 50.0,
riding_turns,
total_cost,
}
}

#[test]
fn aggregates_by_mcp_server_groups_by_server_segment_and_sorts_by_cost() {
// Two MCP servers + a non-MCP tool + a malformed mcp__ name. The
// non-MCP + malformed rows must NOT show up; the relaycast roll-up
// must collapse all three relaycast tools into a single row with
// top_tools sorted by cost desc.
let attrs = vec![
mcp_attribution("mcp__relaycast__send_dm", 2.0, 1),
mcp_attribution("mcp__relaycast__send_dm", 1.5, 0),
mcp_attribution("mcp__relaycast__list_channels", 0.5, 0),
mcp_attribution("mcp__relaycast__react_to_message", 0.25, 0),
mcp_attribution("mcp__github__get_file_contents", 1.0, 2),
mcp_attribution("mcp__github__create_pull_request", 0.1, 0),
// Non-MCP — must be skipped.
mcp_attribution("Read", 99.0, 5),
// Malformed: missing tool segment.
mcp_attribution("mcp__only_server__", 50.0, 0),
// Malformed: missing server segment.
mcp_attribution("mcp____tool_only", 50.0, 0),
// Malformed: not enough separators.
mcp_attribution("mcp__no_double_separator", 50.0, 0),
];

let rows = aggregate_by_mcp_server(&attrs);
assert_eq!(
rows.len(),
2,
"only the two well-formed mcp__ servers should aggregate"
);

// relaycast wins on cumulative cost (2.0 + 1.5 + 0.5 + 0.25 = 4.25)
// vs github (1.0 + 0.1 = 1.1).
let relaycast = &rows[0];
assert_eq!(relaycast.server, "relaycast");
assert_eq!(relaycast.call_count, 4);
assert!((relaycast.total_cost - 4.25).abs() < 1e-9);
assert!((relaycast.initial_tokens - 4.25 * 100.0).abs() < 1e-9);
assert!((relaycast.persistence_tokens - 4.25 * 50.0).abs() < 1e-9);
assert_eq!(relaycast.riding_turns, 1);
assert_eq!(
relaycast.top_tools,
vec!["send_dm", "list_channels", "react_to_message"],
);

let github = &rows[1];
assert_eq!(github.server, "github");
assert_eq!(github.call_count, 2);
assert!((github.total_cost - 1.1).abs() < 1e-9);
assert_eq!(github.riding_turns, 2);
assert_eq!(
github.top_tools,
vec!["get_file_contents", "create_pull_request"],
);
}

#[test]
fn falls_back_to_even_split_when_no_content_is_provided() {
let pricing = load_builtin_pricing();
Expand Down
32 changes: 16 additions & 16 deletions crates/relayburn-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,27 +91,27 @@ pub use crate::ledger::{
};

pub use crate::analyze::{
aggregate_by_bash, aggregate_by_bash_verb, aggregate_by_file, aggregate_by_provider,
aggregate_by_subagent, aggregate_subagent_type_stats, attribute_hotspots, attribute_overhead,
build_compare_table, build_subagent_tree, build_trim_recommendations, compare_from_archive,
compute_quality, cost_for_turn, cost_for_usage, describe_applies_to, detect_patterns,
detect_tool_call_patterns, detect_tool_output_bloat, filter_turns_by_provider,
filter_turns_by_provider_with_rules, find_overhead_files, findings_from_patterns,
has_minimum_fidelity, load_overhead_file, load_pricing, provider_for, AsTurnLike,
ProviderFilter, ProviderRule, render_unified_diff_for_recommendation, sum_costs,
summarize_fidelity, summarize_replacement_savings, AggregateByProviderOptions,
AttributeOverheadInput,
aggregate_by_bash, aggregate_by_bash_verb, aggregate_by_file, aggregate_by_mcp_server,
aggregate_by_provider, aggregate_by_subagent, aggregate_subagent_type_stats,
attribute_hotspots, attribute_overhead, build_compare_table, build_subagent_tree,
build_trim_recommendations, compare_from_archive, compute_quality, cost_for_turn,
cost_for_usage, describe_applies_to, detect_patterns, detect_tool_call_patterns,
detect_tool_output_bloat, filter_turns_by_provider, filter_turns_by_provider_with_rules,
find_overhead_files, findings_from_patterns, has_minimum_fidelity, load_overhead_file,
load_pricing, provider_for, AsTurnLike, ProviderFilter, ProviderRule,
render_unified_diff_for_recommendation, sum_costs, summarize_fidelity,
summarize_replacement_savings, AggregateByProviderOptions, AttributeOverheadInput,
AttributionMethod, BashAggregation, BashVerbAggregation, BuildSubagentTreeOptions,
CompareCategory, CompareCell, CompareFromArchiveResult,
CompareOptions as AnalyzeCompareOptions, CompareTable, CompareTotals, ComputeQualityOptions,
CostBreakdown, CoverageField, FidelitySummary, FieldCoverage, FileAggregation,
HotspotsOptions as AnalyzeHotspotsOptions, HotspotsResult as AnalyzeHotspotsResult,
MarkdownSection, ModelCost, OneShotMetrics, OutcomeLabel, OverheadAttribution, OverheadFile,
OverheadFileAttribution, OverheadFileKind, ParsedOverheadFile, PricingTable,
ProviderAggregateRow, QualityResult, ReasoningMode, ReplacementSavingsSummary, RowCoverage,
SessionClaudeMdCost, SessionOutcome, SessionTotals, SubagentAggregation, SubagentTreeNode,
SubagentTypeStats, ToolAttribution, TrimRecommendation, TurnProvider, UsageCostAggregateRow,
WasteFinding, WasteSeverity, DEFAULT_MIN_SAMPLE,
MarkdownSection, McpServerAggregation, ModelCost, OneShotMetrics, OutcomeLabel,
OverheadAttribution, OverheadFile, OverheadFileAttribution, OverheadFileKind,
ParsedOverheadFile, PricingTable, ProviderAggregateRow, QualityResult, ReasoningMode,
ReplacementSavingsSummary, RowCoverage, SessionClaudeMdCost, SessionOutcome, SessionTotals,
SubagentAggregation, SubagentTreeNode, SubagentTypeStats, ToolAttribution, TrimRecommendation,
TurnProvider, UsageCostAggregateRow, WasteFinding, WasteSeverity, DEFAULT_MIN_SAMPLE,
};

pub use crate::ingest::{
Expand Down
Loading
Loading