mod graph;
mod render;
mod report;
#[cfg(test)]
mod tests;
use std::{
path::{Path, PathBuf},
time::Instant,
};
use rolldown_std_utils::{absolutize_path_buf, normalize_path_buf, relative_path_to_slash};
use rustc_hash::FxHashMap;
use serde::Deserialize;
pub const REPORT_ROOT: &str = "node_modules/.rolldown/report";
#[derive(Debug, Clone)]
pub struct MetricsConfig {
pub dir: String,
pub top_n: usize,
pub delta: bool,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self { dir: REPORT_ROOT.to_string(), top_n: 20, delta: true }
}
}
impl MetricsConfig {
#[must_use]
pub fn new(dir: String) -> Self {
Self { dir, ..Self::default() }
}
}
#[must_use]
pub fn report_dir_for(cwd: &Path, dir: Option<&str>, file: Option<&str>) -> String {
let out_dir = match file {
Some(file) => Path::new(file).parent().map(Path::to_path_buf).unwrap_or_default(),
None => PathBuf::from(dir.unwrap_or("dist")),
};
let cwd = absolutize_path_buf(cwd.to_path_buf());
let relative = relative_path_to_slash(normalize_path_buf(cwd.join(out_dir)), &cwd);
let mut report = String::from(REPORT_ROOT);
for segment in relative.split('/').filter(|segment| !segment.is_empty() && *segment != ".") {
report.push('/');
if segment == ".." {
report.push_str("__");
} else {
report.extend(segment.chars().filter(|c| *c != ':'));
}
}
report
}
#[derive(Default)]
pub(crate) struct ChunkAgg {
pub(crate) name: String,
pub(crate) reason: String,
pub(crate) is_entry: bool,
pub(crate) is_user_entry: bool,
pub(crate) entry_module: Option<String>,
pub(crate) module_count: usize,
pub(crate) static_imports: Vec<u32>,
pub(crate) dynamic_imports: Vec<u32>,
}
pub(crate) struct PackageAgg {
pub(crate) name: String,
pub(crate) version: Option<String>,
pub(crate) dependency_type: String,
pub(crate) size: u64,
pub(crate) module_count: usize,
pub(crate) is_used: bool,
}
pub(crate) struct AssetAgg {
pub(crate) filename: String,
pub(crate) size: u64,
}
#[derive(Default)]
pub(crate) struct HookStat {
pub(crate) count: usize,
pub(crate) micros: u64,
pub(crate) noop_calls: usize,
}
struct PendingCall {
plugin: String,
hook: &'static str,
start: Instant,
module_id: Option<String>,
content_fingerprint: Option<(usize, u64)>,
}
#[derive(Default)]
pub struct MetricsAggregator {
config: MetricsConfig,
cwd: Option<String>,
platform: Option<String>,
format: Option<String>,
input_count: usize,
plugin_count: usize,
build_index: usize,
build_start: Option<Instant>,
scan_end: Option<Instant>,
build_end: Option<Instant>,
module_count: usize,
external_count: usize,
import_kind_hist: FxHashMap<String, usize>,
most_imported: Vec<(String, usize)>,
module_imports: FxHashMap<String, Vec<(String, bool)>>,
module_bytes: FxHashMap<String, u64>,
chunks: FxHashMap<u32, ChunkAgg>,
module_chunks: FxHashMap<String, Vec<u32>>,
package_direct: usize,
package_transitive: usize,
packages: Vec<PackageAgg>,
package_versions: FxHashMap<String, Vec<(String, u64)>>,
asset_count: usize,
total_bytes: u64,
js_bytes: u64,
css_bytes: u64,
other_bytes: u64,
chunk_sizes: FxHashMap<u32, u64>,
chunk_filenames: FxHashMap<u32, String>,
assets: Vec<AssetAgg>,
hook_calls: FxHashMap<(String, &'static str), HookStat>,
module_transform: FxHashMap<String, HookStat>,
pending: FxHashMap<String, PendingCall>,
}
impl MetricsAggregator {
#[must_use]
pub fn new(config: MetricsConfig) -> Self {
Self { config, ..Default::default() }
}
pub fn fold(&mut self, value: &serde_json::Value, at: Instant) {
let Some(action) = value.get("action").and_then(serde_json::Value::as_str) else {
return;
};
match action {
"SessionMeta" => self.fold_session_meta(value),
"BuildStart" => self.on_build_start(at),
"BuildEnd" => {
if self.scan_end.is_none() {
self.scan_end = Some(at);
}
self.build_end = Some(at);
}
"ModuleGraphReady" => self.fold_module_graph(value),
"ModuleRenderedReady" => self.fold_module_rendered(value),
"ChunkGraphReady" => self.fold_chunk_graph(value),
"PackageGraphReady" => self.fold_package_graph(value),
"AssetsReady" => self.fold_assets(value),
"HookResolveIdCallStart" => self.hook_start(value, "resolveId", at),
"HookLoadCallStart" => self.hook_start(value, "load", at),
"HookTransformCallStart" => self.hook_start(value, "transform", at),
"HookRenderChunkStart" => self.hook_start(value, "renderChunk", at),
"HookResolveIdCallEnd"
| "HookLoadCallEnd"
| "HookTransformCallEnd"
| "HookRenderChunkEnd" => self.hook_end(value, at),
_ => {}
}
}
fn on_build_start(&mut self, at: Instant) {
if self.build_end.is_some() {
self.append_history();
}
let is_new_build = self.build_end.is_some() || self.build_start.is_none();
let config = std::mem::take(&mut self.config);
let (cwd, platform, format, input_count, plugin_count, build_index) = (
self.cwd.take(),
self.platform.take(),
self.format.take(),
self.input_count,
self.plugin_count,
self.build_index,
);
*self = Self::new(config);
self.cwd = cwd;
self.platform = platform;
self.format = format;
self.input_count = input_count;
self.plugin_count = plugin_count;
self.build_index = if is_new_build { build_index + 1 } else { build_index };
self.build_start = Some(at);
}
fn fold_session_meta(&mut self, value: &serde_json::Value) {
if let Ok(meta) = serde_json::from_value::<MSessionMeta>(value.clone()) {
self.cwd = Some(meta.cwd);
self.platform = Some(meta.platform);
self.format = Some(meta.format);
self.input_count = meta.inputs.len();
self.plugin_count = meta.plugins.len();
}
}
fn fold_module_graph(&mut self, value: &serde_json::Value) {
let Ok(graph) = serde_json::from_value::<MModuleGraph>(value.clone()) else {
return;
};
self.module_count = graph.modules.len();
self.external_count = 0;
self.import_kind_hist.clear();
self.module_imports.clear();
let mut imported: Vec<(String, usize)> = Vec::new();
for module in graph.modules {
if module.is_external {
self.external_count += 1;
}
let mut deps: Vec<(String, bool)> = Vec::new();
if let Some(imports) = module.imports {
for import in imports {
let is_dynamic = import.kind == "dynamic-import";
*self.import_kind_hist.entry(import.kind).or_default() += 1;
deps.push((import.module_id, is_dynamic));
}
}
let importer_count = module.importers.map_or(0, |i| i.len());
if importer_count >= 2 {
imported.push((module.id.clone(), importer_count));
}
if !deps.is_empty() {
self.module_imports.insert(module.id, deps);
}
}
imported.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
imported.truncate(self.config.top_n);
self.most_imported = imported;
}
fn fold_module_rendered(&mut self, value: &serde_json::Value) {
let Ok(rendered) = serde_json::from_value::<MModuleRendered>(value.clone()) else {
return;
};
self.module_bytes.clear();
for module in rendered.modules {
self.module_bytes.insert(module.id, u64::from(module.bytes));
}
}
fn fold_chunk_graph(&mut self, value: &serde_json::Value) {
let Ok(graph) = serde_json::from_value::<MChunkGraph>(value.clone()) else {
return;
};
self.chunks.clear();
self.module_chunks.clear();
let multi_chunk = graph.chunks.len() > 1;
for chunk in graph.chunks {
let mut static_imports = Vec::new();
let mut dynamic_imports = Vec::new();
for import in chunk.imports {
if import.kind == "dynamic-import" {
dynamic_imports.push(import.chunk_id);
} else {
static_imports.push(import.chunk_id);
}
}
if multi_chunk {
for module_id in &chunk.modules {
self.module_chunks.entry(module_id.clone()).or_default().push(chunk.chunk_id);
}
}
self.chunks.insert(
chunk.chunk_id,
ChunkAgg {
name: chunk.name.unwrap_or_else(|| format!("chunk-{}", chunk.chunk_id)),
reason: chunk.reason,
is_entry: chunk.is_user_defined_entry || chunk.is_async_entry,
is_user_entry: chunk.is_user_defined_entry,
entry_module: chunk.entry_module,
module_count: chunk.modules.len(),
static_imports,
dynamic_imports,
},
);
}
}
fn fold_package_graph(&mut self, value: &serde_json::Value) {
let Ok(graph) = serde_json::from_value::<MPackageGraph>(value.clone()) else {
return;
};
self.package_direct = 0;
self.package_transitive = 0;
self.package_versions.clear();
let mut packages: Vec<PackageAgg> = Vec::with_capacity(graph.packages.len());
for package in graph.packages {
if package.dependency_type == "direct" {
self.package_direct += 1;
} else {
self.package_transitive += 1;
}
let name = package.name.unwrap_or_else(|| "<unknown>".to_string());
let size = u64::from(package.size);
self
.package_versions
.entry(name.clone())
.or_default()
.push((package.version.clone().unwrap_or_default(), size));
packages.push(PackageAgg {
name,
version: package.version,
dependency_type: package.dependency_type,
size,
module_count: package.modules.len(),
is_used: package.is_used,
});
}
packages.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.name.cmp(&b.name)));
packages.truncate(self.config.top_n);
self.packages = packages;
}
fn fold_assets(&mut self, value: &serde_json::Value) {
let Ok(assets) = serde_json::from_value::<MAssets>(value.clone()) else {
return;
};
self.asset_count = 0;
self.total_bytes = 0;
self.js_bytes = 0;
self.css_bytes = 0;
self.other_bytes = 0;
self.chunk_sizes.clear();
self.chunk_filenames.clear();
let mut list: Vec<AssetAgg> = Vec::new();
for asset in assets.assets {
if asset.filename.ends_with(".map") {
continue;
}
let size = u64::from(asset.size);
self.asset_count += 1;
self.total_bytes += size;
if let Some(chunk_id) = asset.chunk_id {
self.js_bytes += size;
self.chunk_sizes.insert(chunk_id, size);
self.chunk_filenames.insert(chunk_id, asset.filename.clone());
} else if asset.filename.ends_with(".css") {
self.css_bytes += size;
} else {
self.other_bytes += size;
}
list.push(AssetAgg { filename: asset.filename, size });
}
list.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.filename.cmp(&b.filename)));
list.truncate(self.config.top_n);
self.assets = list;
}
fn hook_start(&mut self, value: &serde_json::Value, hook: &'static str, at: Instant) {
let (Some(call_id), Some(plugin)) =
(str_field(value, "call_id"), str_field(value, "plugin_name"))
else {
return;
};
let content_fingerprint = if hook == "transform" { content_fingerprint(value) } else { None };
self.pending.insert(
call_id,
PendingCall {
plugin,
hook,
start: at,
module_id: str_field(value, "module_id"),
content_fingerprint,
},
);
}
fn hook_end(&mut self, value: &serde_json::Value, at: Instant) {
let Some(call_id) = str_field(value, "call_id") else {
return;
};
let Some(call) = self.pending.remove(&call_id) else {
return;
};
let micros = u64::try_from(at.duration_since(call.start).as_micros()).unwrap_or(u64::MAX);
let is_noop = match call.hook {
"resolveId" => field_is_null(value, "resolved_id"),
"load" | "renderChunk" => field_is_null(value, "content"),
"transform" => match (call.content_fingerprint, content_fingerprint(value)) {
(Some(input), Some(output)) => input == output,
_ => field_is_null(value, "content"),
},
_ => false,
};
let stat = self.hook_calls.entry((call.plugin, call.hook)).or_default();
stat.count += 1;
stat.micros += micros;
stat.noop_calls += usize::from(is_noop);
if call.hook == "transform"
&& let Some(module_id) = call.module_id
{
let entry = self.module_transform.entry(module_id).or_default();
entry.count += 1;
entry.micros += micros;
}
}
}
fn str_field(value: &serde_json::Value, key: &str) -> Option<String> {
value.get(key).and_then(serde_json::Value::as_str).map(ToString::to_string)
}
fn field_is_null(value: &serde_json::Value, key: &str) -> bool {
value.get(key).is_none_or(serde_json::Value::is_null)
}
fn content_fingerprint(value: &serde_json::Value) -> Option<(usize, u64)> {
use std::hash::Hasher as _;
let content = value.get("content")?.as_str()?;
let mut hasher = rustc_hash::FxHasher::default();
hasher.write(content.as_bytes());
Some((content.len(), hasher.finish()))
}
#[derive(Deserialize)]
struct MSessionMeta {
#[serde(default)]
inputs: Vec<serde_json::Value>,
#[serde(default)]
plugins: Vec<serde_json::Value>,
#[serde(default)]
cwd: String,
#[serde(default)]
platform: String,
#[serde(default)]
format: String,
}
#[derive(Deserialize)]
struct MModuleGraph {
modules: Vec<MModule>,
}
#[derive(Deserialize)]
struct MModule {
id: String,
#[serde(default)]
is_external: bool,
#[serde(default)]
imports: Option<Vec<MImport>>,
#[serde(default)]
importers: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct MImport {
kind: String,
#[serde(default)]
module_id: String,
}
#[derive(Deserialize)]
struct MModuleRendered {
modules: Vec<MRenderedModule>,
}
#[derive(Deserialize)]
struct MRenderedModule {
id: String,
#[serde(default)]
bytes: u32,
}
#[derive(Deserialize)]
struct MChunkGraph {
chunks: Vec<MChunk>,
}
#[derive(Deserialize)]
struct MChunk {
chunk_id: u32,
#[serde(default)]
name: Option<String>,
#[serde(default)]
is_user_defined_entry: bool,
#[serde(default)]
is_async_entry: bool,
#[serde(default)]
entry_module: Option<String>,
#[serde(default)]
modules: Vec<String>,
reason: String,
#[serde(default)]
imports: Vec<MChunkImport>,
}
#[derive(Deserialize)]
struct MChunkImport {
chunk_id: u32,
kind: String,
}
#[derive(Deserialize)]
struct MPackageGraph {
packages: Vec<MPackage>,
}
#[derive(Deserialize)]
struct MPackage {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
is_used: bool,
dependency_type: String,
#[serde(default)]
size: u32,
#[serde(default)]
modules: Vec<String>,
}
#[derive(Deserialize)]
struct MAssets {
assets: Vec<MAsset>,
}
#[derive(Deserialize)]
struct MAsset {
#[serde(default)]
chunk_id: Option<u32>,
#[serde(default)]
size: u32,
filename: String,
}