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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

> Note: this file did not exist until after `v0.5.6`.
## [Unreleased]
## Unreleased

- add `--time-to-inclusion-bucket` flag to configure histogram bucket size in reports ([#498](https://github.com/flashbots/contender/pull/498))
- move default data dir to `$XDG_STATE_HOME/contender` (`~/.local/state/contender`), with automatic migration from legacy `~/.contender` ([#460](https://github.com/flashbots/contender/issues/460))

## [0.9.0](https://github.com/flashbots/contender/releases/tag/v0.9.0) - 2026-03-17
Expand Down
9 changes: 5 additions & 4 deletions crates/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## Unreleased

- move default data dir from `~/.contender` to `$XDG_STATE_HOME/contender` (defaults to `~/.local/state/contender`), with automatic migration of existing data ([#460](https://github.com/flashbots/contender/issues/460))
- add `--time-to-inclusion-bucket` flag to configure histogram bucket size in reports ([#498](https://github.com/flashbots/contender/pull/498/changes))
- move default data dir from `~/.contender` to `$XDG_STATE_HOME/contender` (defaults to `~/.local/state/contender`), with automatic migration of existing data ([#460](https://github.com/flashbots/contender/issues/460/changes))

## [0.9.0](https://github.com/flashbots/contender/releases/tag/v0.9.0) - 2026-03-17

- added `--send-raw-tx-sync` flag to `spam` and `campaign` commands ([#459](https://github.com/flashbots/contender/pull/459))
- added `--send-raw-tx-sync` flag to `spam` and `campaign` commands ([#459](https://github.com/flashbots/contender/pull/459/changes))
- changed internal erc20 defaults (didn't match cli defaults) ([#443](https://github.com/flashbots/contender/pull/443/changes))
- added chainlink scenario to repo scenarios ([#446](https://github.com/flashbots/contender/pull/446))
- added chainlink scenario to repo scenarios ([#446](https://github.com/flashbots/contender/pull/446/changes))
- use `std::path::Path` instead of `str` where applicable, add data_dir arg to enable custom data dir at runtime ([453](https://github.com/flashbots/contender/pull/453/changes))
- add json option to `report` ([#453](https://github.com/flashbots/contender/pull/453/changes))
- added `--scenario-label` flag to deploy and spam the same scenario under different labels ([#456](https://github.com/flashbots/contender/pull/456/changes))
Expand Down
20 changes: 11 additions & 9 deletions crates/cli/src/commands/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::BuiltinScenarioCli;
use alloy::primitives::{keccak256, U256};
use clap::Args;
use contender_core::error::RuntimeParamErrorKind;
use contender_report::command::ReportParams;
use contender_testfile::{CampaignConfig, CampaignMode, ResolvedMixEntry, ResolvedStage};
use std::path::Path;
use std::time::Duration;
Expand Down Expand Up @@ -134,6 +135,10 @@ pub struct CampaignCliArgs {
long_help = "Skip per-transaction debug traces (debug_traceTransaction) when generating the campaign report. This significantly speeds up report generation for large runs at the cost of omitting the storage heatmap and tx gas used charts."
)]
pub skip_tx_traces: bool,

/// Bucket size in milliseconds for the time-to-inclusion histogram.
#[arg(long, default_value_t = 1000, value_name = "MS", value_parser = clap::value_parser!(u64).range(1..=10000))]
pub time_to_inclusion_bucket: u64,
}

fn bump_seed(base_seed: &str, stage_name: &str) -> String {
Expand Down Expand Up @@ -266,15 +271,12 @@ pub async fn run_campaign(
run_ids.sort_unstable();
let first_run = *run_ids.first().expect("run IDs exist");
let last_run = *run_ids.last().expect("run IDs exist");
contender_report::command::report(
Some(last_run),
last_run - first_run,
db,
data_dir,
false, // use HTML format by default for campaign reports
args.skip_tx_traces,
)
.await?;
let report_params = ReportParams::new()
.with_skip_tx_traces(args.skip_tx_traces)
.with_time_to_inclusion_bucket(args.time_to_inclusion_bucket)
.with_last_run_id(last_run)
.with_preceding_runs(last_run - first_run);
contender_report::command::report(db, data_dir, report_params).await?;
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/commands/contender_subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ pub enum ContenderSubcommand {
/// at the cost of omitting the storage heatmap and tx gas used charts.
#[arg(long)]
skip_tx_traces: bool,

/// Bucket size in milliseconds for the time-to-inclusion histogram.
#[arg(long, default_value_t = 1000, value_name = "MS", value_parser = clap::value_parser!(u64).range(1..=10000))]
time_to_inclusion_bucket: u64,
},

#[command(
Expand Down
12 changes: 3 additions & 9 deletions crates/cli/src/commands/spam.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use contender_core::{
use contender_engine_provider::{
reth_node_api::EngineApiMessageVersion, AuthProvider, ControlChain,
};
use contender_report::command::ReportParams;
use contender_testfile::TestConfig;
use op_alloy_network::{Ethereum, Optimism};
use serde::Serialize;
Expand Down Expand Up @@ -972,15 +973,8 @@ pub async fn spam<D: GenericDb>(
let run_id = spam_inner(db, &mut test_scenario, args, run_context).await?;
if args.spam_args.gen_report {
if let Some(run_id) = &run_id {
contender_report::command::report(
Some(*run_id),
0,
db,
&resolve_data_dir(None)?,
false, // TODO: support JSON reports, maybe add a CLI flag for it
false,
)
.await?;
let report_params = ReportParams::new().with_last_run_id(*run_id);
contender_report::command::report(db, &resolve_data_dir(None)?, report_params).await?;
} else {
warn!("Cannot generate report: no run ID found.");
}
Expand Down
31 changes: 19 additions & 12 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use commands::{
SpamScenario,
};
use contender_core::{db::DbOps, util::TracingOptions};
use contender_report::command::ReportParams;
use contender_sqlite::{SqliteDb, DB_VERSION};
use default_scenarios::{fill_block::FillBlockCliArgs, BuiltinScenarioCli};
use error::CliError;
Expand Down Expand Up @@ -132,7 +133,9 @@ async fn run() -> Result<(), CliError> {
campaign_id,
format,
skip_tx_traces,
time_to_inclusion_bucket,
} => {
let use_json = matches!(format, ReportFormat::Json);
if let Some(campaign_id) = campaign_id {
let resolved_campaign_id = if campaign_id == "__LATEST_CAMPAIGN__" {
db.latest_campaign_id()
Expand All @@ -148,26 +151,30 @@ async fn run() -> Result<(), CliError> {
if preceding_runs > 0 {
warn!("--preceding-runs is ignored when --campaign is provided");
}
let report_params = ReportParams::new()
.with_skip_tx_traces(skip_tx_traces)
.with_time_to_inclusion_bucket(time_to_inclusion_bucket)
.with_use_json(use_json);
contender_report::command::report_campaign(
&resolved_campaign_id,
&db,
&data_dir,
skip_tx_traces,
report_params,
)
.await
.map_err(CliError::Report)?;
} else {
let use_json = matches!(format, ReportFormat::Json);
contender_report::command::report(
last_run_id,
preceding_runs,
&db,
&data_dir,
use_json,
skip_tx_traces,
)
.await
.map_err(CliError::Report)?;
let mut report_params = ReportParams::new()
.with_preceding_runs(preceding_runs)
.with_skip_tx_traces(skip_tx_traces)
.with_time_to_inclusion_bucket(time_to_inclusion_bucket)
.with_use_json(use_json);
if let Some(last_run_id) = last_run_id {
report_params = report_params.with_last_run_id(last_run_id);
}
contender_report::command::report(&db, &data_dir, report_params)
.await
.map_err(CliError::Report)?;
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/report/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- make time-to-inclusion histogram bucket size configurable; labels display in seconds when bucket size is a multiple of 1000ms

## [0.9.0](https://github.com/flashbots/contender/releases/tag/v0.9.0) - 2026-03-17

- added RPC report template and `report_rpc()` for generating HTML reports from `contender rpc` runs
Expand Down
73 changes: 68 additions & 5 deletions crates/report/src/chart/time_to_inclusion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
pub struct TimeToInclusionChart {
/// Contains each tx's time to inclusion in milliseconds.
inclusion_times_ms: Vec<u64>,
/// Bucket size in milliseconds for the histogram.
bucket_size_ms: u64,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand All @@ -14,7 +16,7 @@ pub struct TimeToInclusionData {
}

impl TimeToInclusionChart {
pub fn new(run_txs: &[RunTx]) -> Self {
pub fn new(run_txs: &[RunTx], bucket_size_ms: u64) -> Self {
let mut inclusion_times_ms = vec![];
for tx in run_txs {
let mut dumb_base = 0;
Expand All @@ -28,17 +30,20 @@ impl TimeToInclusionChart {
inclusion_times_ms.push(tti_ms);
}
}
Self { inclusion_times_ms }
Self {
inclusion_times_ms,
bucket_size_ms,
}
}

pub fn echart_data(&self) -> TimeToInclusionData {
let mut buckets = vec![];
let mut counts = vec![];
let mut max_count = 0;

// 1000ms (1s) per bucket
let bucket_size_ms = self.bucket_size_ms;
for &tti_ms in &self.inclusion_times_ms {
let bucket_index = (tti_ms / 1000) as usize;
let bucket_index = (tti_ms / bucket_size_ms) as usize;
if bucket_index >= buckets.len() {
buckets.resize(bucket_index + 1, "".to_string());
counts.resize(bucket_index + 1, 0);
Expand All @@ -47,7 +52,15 @@ impl TimeToInclusionChart {
if counts[bucket_index] > max_count {
max_count = counts[bucket_index];
}
buckets[bucket_index] = format!("{} - {} s", bucket_index, bucket_index + 1);
let start_ms = bucket_index as u64 * bucket_size_ms;
let end_ms = start_ms + bucket_size_ms;
buckets[bucket_index] = if bucket_size_ms % 1000 == 0 {
let start_s = start_ms / 1000;
let end_s = end_ms / 1000;
format!("{start_s} - {end_s} s")
} else {
format!("{start_ms} - {end_ms} ms")
};
}

// Filter out empty buckets and counts that are zero
Expand All @@ -64,3 +77,53 @@ impl TimeToInclusionChart {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use alloy::primitives::TxHash;

fn make_tx(start_ms: u64, end_ms: u64) -> RunTx {
RunTx {
tx_hash: TxHash::ZERO,
start_timestamp_ms: start_ms,
end_timestamp_ms: Some(end_ms),
block_number: Some(1),
gas_used: Some(21000),
kind: None,
error: None,
flashblock_latency_ms: None,
flashblock_index: None,
}
}

#[test]
fn ms_buckets() {
// 50ms and 80ms fall in bucket 0 (0-100ms), 150ms in bucket 1
let txs = vec![make_tx(0, 50), make_tx(0, 80), make_tx(0, 150)];
let data = TimeToInclusionChart::new(&txs, 100).echart_data();

assert_eq!(data.buckets, vec!["0 - 100 ms", "100 - 200 ms"]);
assert_eq!(data.counts, vec![2, 1]);
assert_eq!(data.max_count, 2);
}

#[test]
fn second_buckets() {
// bucket size is a multiple of 1000ms, so labels should be in seconds
let txs = vec![make_tx(0, 500), make_tx(0, 1500)];
let data = TimeToInclusionChart::new(&txs, 1000).echart_data();

assert_eq!(data.buckets, vec!["0 - 1 s", "1 - 2 s"]);
assert_eq!(data.counts, vec![1, 1]);
}

#[test]
fn empty_input() {
let data = TimeToInclusionChart::new(&[], 1000).echart_data();

assert!(data.buckets.is_empty());
assert!(data.counts.is_empty());
assert_eq!(data.max_count, 0);
}
}
Loading