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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ Defined in `.cargo/config.toml`:
- `cargo gen-types` / `cargo gen-spec` / `cargo gen-ts` - Type generation
- `cargo test-api` - API testing and DB seeding
- `cargo test-api seed` needs the API server already running (`cargo run` in `services/api`) with a fresh database (`services/api/data` holds only a tracked `.gitignore`; delete `services/api/data/bencher.db`, not the whole directory)
- `cargo test-api seed` also needs the `bencher` CLI binary already built (`cargo build --bin bencher`); it shells out via `assert_cmd`, which panics with `` `CARGO_BIN_EXE_bencher` is unset `` if the binary is missing
- Pass `--no-git` when running the seed test in this repo: there is no colocated `.git`, so `bencher run` cannot derive a git context and the on-the-fly project naming assertions (`bencher` vs `Project`) will fail without it
- `cargo test-runner` - Runner integration tests (requires Linux + KVM)

Expand Down
16 changes: 13 additions & 3 deletions lib/api_projects/src/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ use bencher_schema::{
head::HeadId,
version::{QueryVersion, VersionId},
},
report::{NewRunReport, QueryReport, ReportId, report_benchmark::ReportBenchmarkId},
report::{
NewRunReport, QueryReport, ReportId, ReportMode,
report_benchmark::ReportBenchmarkId,
},
},
user::{
actor::{ApiActor, PubProjectBearerToken},
Expand Down Expand Up @@ -87,6 +90,8 @@ pub async fn proj_reports_options(
/// If the project is private, then the user must be authenticated and have `view` permissions for the project,
/// or provide a valid project key for the project.
/// By default, the reports are sorted by date time in reverse chronological order.
/// By default, the results and alerts for each report are omitted and only their counts are included.
/// Set the `expand` query param to `true` to include the full results and alerts.
/// The HTTP response header `X-Total-Count` contains the total number of reports.
#[endpoint {
method = GET,
Expand Down Expand Up @@ -149,10 +154,15 @@ async fn get_ls_inner(
(&query_project, &pagination_params, &query_params)
))?;

let mode = if query_params.expand.unwrap_or_default() {
ReportMode::Full
} else {
ReportMode::Collapsed
};
// Drop connection lock before iterating
let json_reports = reports
.into_iter()
.map(|report| async { report.into_json(log, actor_conn!(context, api_actor)) })
.map(|report| async { report.into_json(log, actor_conn!(context, api_actor), mode) })
.collect::<FuturesOrdered<_>>()
.collect::<Vec<_>>()
.await
Expand Down Expand Up @@ -419,7 +429,7 @@ async fn get_one_inner(
Report,
(&query_project, path_params.report)
))?
.into_json(log, conn)
.into_json(log, conn, ReportMode::Full)
})
}

Expand Down
5 changes: 3 additions & 2 deletions lib/api_projects/tests/project_key_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1114,12 +1114,13 @@ async fn project_key_can_dismiss_alert() {
let report: JsonReport = resp.json().await.expect("Failed to parse report");

// Check that an alert was generated
let alerts = report.alerts.as_deref().expect("Report missing alerts");
assert!(
!report.alerts.is_empty(),
!alerts.is_empty(),
"Expected at least one alert from the spike run"
);

let alert_uuid = report.alerts[0].uuid;
let alert_uuid = alerts[0].uuid;

// Dismiss the alert with the project key
let resp = server
Expand Down
179 changes: 178 additions & 1 deletion lib/api_projects/tests/reports.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#![expect(
unused_crate_dependencies,
clippy::expect_used,
clippy::missing_assert_message,
clippy::tests_outside_test_module,
clippy::uninlined_format_args,
reason = "integration test file"
Expand All @@ -11,7 +13,7 @@ use bencher_api_tests::{
helpers::{base_timestamp, create_test_report, get_project_id},
};
use bencher_json::{
BenchmarkUuid, BoundaryUuid, JsonReports, MeasureUuid, MetricUuid, ModelUuid,
BenchmarkUuid, BoundaryUuid, JsonReport, JsonReports, MeasureUuid, MetricUuid, ModelUuid,
ReportBenchmarkUuid, ThresholdUuid,
};
use bencher_schema::{
Expand Down Expand Up @@ -299,6 +301,43 @@ async fn reports_delete_chunked() {
delete_report_and_assert_empty(count).await;
}

/// Create a report with two iterations, each with two benchmarks and one measure.
async fn post_report(server: &TestServer, token: &str, project_slug: &str) -> JsonReport {
let resp = server
.client
.post(server.api_url(&format!("/v0/projects/{}/reports", project_slug)))
.header(bencher_json::AUTHORIZATION, bencher_json::bearer_header(token))
.json(&serde_json::json!({
"branch": "main",
"testbed": "localhost",
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-01-01T00:01:00Z",
"results": [
"{\"bench_one\": {\"latency\": {\"value\": 100.0}}, \"bench_two\": {\"latency\": {\"value\": 200.0}}}",
"{\"bench_one\": {\"latency\": {\"value\": 101.0}}, \"bench_two\": {\"latency\": {\"value\": 201.0}}}"
]
}))
.send()
.await
.expect("Request failed");

assert_eq!(resp.status(), StatusCode::CREATED);
resp.json().await.expect("Failed to parse response")
}

fn assert_counts(counts: &serde_json::Value) {
assert_eq!(
counts,
&serde_json::json!({
"results": [
{ "benchmarks": 2, "measures": 1 },
{ "benchmarks": 2, "measures": 1 }
],
"alerts": { "total": 0, "active": 0 }
})
);
}

// GET /v0/projects/{project}/reports - list reports (empty)
#[tokio::test]
async fn reports_list_empty() {
Expand Down Expand Up @@ -383,6 +422,144 @@ async fn reports_get_not_found() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

// GET /v0/projects/{project}/reports - results and alerts are collapsed by default
#[tokio::test]
async fn reports_list_collapses_by_default() {
let server = TestServer::new().await;
let user = server
.signup("Test User", "reportcollapse@example.com")
.await;
let org = server.create_org(&user, "Report Collapse Org").await;
let project = server
.create_project(&user, &org, "Report Collapse Project")
.await;

let project_slug: &str = project.slug.as_ref();
post_report(&server, &user.token, project_slug).await;

let resp = server
.client
.get(server.api_url(&format!("/v0/projects/{}/reports", project_slug)))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.send()
.await
.expect("Request failed");

assert_eq!(resp.status(), StatusCode::OK);
let reports: serde_json::Value = resp.json().await.expect("Failed to parse response");
let report = reports
.as_array()
.expect("Response is not an array")
.first()
.expect("Response is empty");
// The results and alerts keys are omitted entirely, not just empty
assert!(report.get("results").is_none());
assert!(report.get("alerts").is_none());
assert_counts(report.get("counts").expect("Report missing counts"));

// The collapsed response still parses as the typed JsonReports
let reports: JsonReports =
serde_json::from_value(reports).expect("Failed to parse typed response");
let report = reports.0.first().expect("Reports are empty");
assert!(report.results.is_none());
assert!(report.alerts.is_none());
}

// GET /v0/projects/{project}/reports?expand=true - full results and alerts
#[tokio::test]
async fn reports_list_expand_true_includes_full_report() {
let server = TestServer::new().await;
let user = server.signup("Test User", "reportexpand@example.com").await;
let org = server.create_org(&user, "Report Expand Org").await;
let project = server
.create_project(&user, &org, "Report Expand Project")
.await;

let project_slug: &str = project.slug.as_ref();
post_report(&server, &user.token, project_slug).await;

let resp = server
.client
.get(server.api_url(&format!(
"/v0/projects/{}/reports?expand=true",
project_slug
)))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.send()
.await
.expect("Request failed");

assert_eq!(resp.status(), StatusCode::OK);
let reports: JsonReports = resp.json().await.expect("Failed to parse response");
let report = reports.0.first().expect("Reports are empty");
let results = report.results.as_ref().expect("Report missing results");
let alerts = report.alerts.as_ref().expect("Report missing alerts");
// The counts are consistent with the full results and alerts
assert_eq!(results.len(), report.counts.results.len());
for (iteration, counts) in results.iter().zip(&report.counts.results) {
assert_eq!(iteration.len(), counts.benchmarks as usize);
}
assert_eq!(alerts.len(), report.counts.alerts.total as usize);
}

// GET /v0/projects/{project}/reports/{report} - always returns the full report
#[tokio::test]
async fn reports_get_one_always_full() {
let server = TestServer::new().await;
let user = server.signup("Test User", "reportgetone@example.com").await;
let org = server.create_org(&user, "Report GetOne Org").await;
let project = server
.create_project(&user, &org, "Report GetOne Project")
.await;

let project_slug: &str = project.slug.as_ref();
let report = post_report(&server, &user.token, project_slug).await;

let resp = server
.client
.get(server.api_url(&format!(
"/v0/projects/{}/reports/{}",
project_slug, report.uuid
)))
.header(
bencher_json::AUTHORIZATION,
bencher_json::bearer_header(&user.token),
)
.send()
.await
.expect("Request failed");

assert_eq!(resp.status(), StatusCode::OK);
let report: JsonReport = resp.json().await.expect("Failed to parse response");
assert!(report.results.is_some());
assert!(report.alerts.is_some());
}

// POST /v0/projects/{project}/reports - response includes full results, alerts, and counts
#[tokio::test]
async fn reports_post_full_with_counts() {
let server = TestServer::new().await;
let user = server.signup("Test User", "reportpost@example.com").await;
let org = server.create_org(&user, "Report Post Org").await;
let project = server
.create_project(&user, &org, "Report Post Project")
.await;

let project_slug: &str = project.slug.as_ref();
let report = post_report(&server, &user.token, project_slug).await;

assert!(report.results.is_some());
assert!(report.alerts.is_some());
let counts = serde_json::to_value(&report.counts).expect("Failed to serialize counts");
assert_counts(&counts);
}

// DELETE /v0/projects/{project}/reports/{report} - not found
#[tokio::test]
async fn reports_delete_not_found() {
Expand Down
Loading
Loading