Skip to content

Commit e63cd2d

Browse files
karthiknadigCopilot
andcommitted
perf: report refresh phase distributions (#504)
Capture existing RefreshProgress notifications in the E2E benchmark and emit deterministic phase and locator percentile distributions so cold-tail latency can be attributed before product changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 51f2026 commit e63cd2d

1 file changed

Lines changed: 164 additions & 4 deletions

File tree

crates/pet/tests/e2e_performance.rs

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
//! These tests spawn the pet server as a subprocess and communicate via JSONRPC
77
//! to measure discovery performance from a client perspective.
88
9+
use pet_core::telemetry::refresh_progress::{
10+
RefreshProgress, RefreshProgressPhase, RefreshProgressStatus,
11+
};
912
use serde::Deserialize;
1013
use serde_json::{json, Value};
11-
use std::collections::{HashMap, VecDeque};
14+
use std::collections::{BTreeMap, HashMap, VecDeque};
1215
use std::env;
1316
use std::io::{BufRead, BufReader, Read, Write};
1417
use std::path::PathBuf;
@@ -199,6 +202,7 @@ pub struct Manager {
199202
struct SharedState {
200203
environments: Mutex<Vec<Environment>>,
201204
managers: Mutex<Vec<Manager>>,
205+
refresh_progress: Mutex<Vec<RefreshProgress>>,
202206
first_env_time: Mutex<Option<Instant>>,
203207
}
204208

@@ -207,6 +211,7 @@ impl SharedState {
207211
Self {
208212
environments: Mutex::new(Vec::new()),
209213
managers: Mutex::new(Vec::new()),
214+
refresh_progress: Mutex::new(Vec::new()),
210215
first_env_time: Mutex::new(None),
211216
}
212217
}
@@ -231,9 +236,23 @@ impl SharedState {
231236
self.managers.lock().unwrap().push(mgr);
232237
}
233238
}
234-
"log" | "telemetry" => {
235-
// Ignore log and telemetry notifications
239+
"telemetry" => {
240+
if params.get("event").and_then(Value::as_str) == Some("RefreshProgress") {
241+
if let Some(progress) = params
242+
.get("data")
243+
.and_then(|data| data.get("refreshProgress"))
244+
.and_then(|value| {
245+
serde_json::from_value::<RefreshProgress>(value.clone()).ok()
246+
})
247+
{
248+
self.refresh_progress
249+
.lock()
250+
.expect("refresh progress mutex poisoned")
251+
.push(progress);
252+
}
253+
}
236254
}
255+
"log" => {}
237256
_ => {
238257
// Unknown notification
239258
}
@@ -243,6 +262,10 @@ impl SharedState {
243262
fn clear(&self) {
244263
self.environments.lock().unwrap().clear();
245264
self.managers.lock().unwrap().clear();
265+
self.refresh_progress
266+
.lock()
267+
.expect("refresh progress mutex poisoned")
268+
.clear();
246269
*self.first_env_time.lock().unwrap() = None;
247270
}
248271
}
@@ -417,6 +440,14 @@ impl PetClient {
417440
self.state.managers.lock().unwrap().clone()
418441
}
419442

443+
fn get_refresh_progress(&self) -> Vec<RefreshProgress> {
444+
self.state
445+
.refresh_progress
446+
.lock()
447+
.expect("refresh progress mutex poisoned")
448+
.clone()
449+
}
450+
420451
/// Get time from start to first environment
421452
pub fn time_to_first_env(&self) -> Option<Duration> {
422453
self.state
@@ -594,6 +625,102 @@ fn stderr_reader_drains_input_and_bounds_diagnostic_tail() {
594625
assert_eq!(tail.back().map(String::as_str), Some("line 104"));
595626
}
596627

628+
fn refresh_phase_name(phase: RefreshProgressPhase) -> &'static str {
629+
match phase {
630+
RefreshProgressPhase::Locators => "locators",
631+
RefreshProgressPhase::Path => "path",
632+
RefreshProgressPhase::GlobalVirtualEnvs => "globalVirtualEnvs",
633+
RefreshProgressPhase::Workspaces => "workspaces",
634+
}
635+
}
636+
637+
fn collect_refresh_progress(
638+
progress: &[RefreshProgress],
639+
phase_stats: &mut BTreeMap<String, StatisticalMetrics>,
640+
locator_stats: &mut BTreeMap<String, StatisticalMetrics>,
641+
) {
642+
for event in progress
643+
.iter()
644+
.filter(|event| event.status == RefreshProgressStatus::Completed)
645+
{
646+
if let (Some(locator), Some(duration)) = (&event.locator_name, event.locator_elapsed_ms) {
647+
locator_stats
648+
.entry(locator.clone())
649+
.or_default()
650+
.add(duration);
651+
} else if let Some(duration) = event.phase_elapsed_ms {
652+
phase_stats
653+
.entry(refresh_phase_name(event.phase).to_string())
654+
.or_default()
655+
.add(duration);
656+
}
657+
}
658+
}
659+
660+
fn statistics_json(statistics: &BTreeMap<String, StatisticalMetrics>) -> BTreeMap<String, Value> {
661+
statistics
662+
.iter()
663+
.map(|(name, metrics)| (name.clone(), metrics.to_json()))
664+
.collect()
665+
}
666+
667+
#[test]
668+
fn refresh_progress_notifications_are_collected() {
669+
let state = SharedState::new();
670+
state.handle_notification(
671+
"telemetry",
672+
json!({
673+
"event": "RefreshProgress",
674+
"data": {
675+
"refreshProgress": {
676+
"refreshId": 7,
677+
"phase": "locators",
678+
"status": "completed",
679+
"elapsedMs": 25,
680+
"locatorName": "Conda",
681+
"locatorElapsedMs": 20
682+
}
683+
}
684+
}),
685+
);
686+
687+
let progress = state.refresh_progress.lock().unwrap();
688+
assert_eq!(progress.len(), 1);
689+
assert_eq!(progress[0].locator_name.as_deref(), Some("Conda"));
690+
assert_eq!(progress[0].locator_elapsed_ms, Some(20));
691+
}
692+
693+
#[test]
694+
fn refresh_progress_aggregation_separates_phases_and_locators() {
695+
let progress = vec![
696+
RefreshProgress {
697+
refresh_id: 1,
698+
phase: RefreshProgressPhase::Locators,
699+
status: RefreshProgressStatus::Completed,
700+
elapsed_ms: 30,
701+
phase_elapsed_ms: Some(30),
702+
locator_name: None,
703+
locator_elapsed_ms: None,
704+
},
705+
RefreshProgress {
706+
refresh_id: 1,
707+
phase: RefreshProgressPhase::Locators,
708+
status: RefreshProgressStatus::Completed,
709+
elapsed_ms: 25,
710+
phase_elapsed_ms: None,
711+
locator_name: Some("Conda".to_string()),
712+
locator_elapsed_ms: Some(20),
713+
},
714+
];
715+
let mut phases = BTreeMap::new();
716+
let mut locators = BTreeMap::new();
717+
718+
collect_refresh_progress(&progress, &mut phases, &mut locators);
719+
720+
assert_eq!(phases["locators"].samples, vec![30]);
721+
assert_eq!(locators["Conda"].samples, vec![20]);
722+
}
723+
597724
// ============================================================================
598725
// Performance Tests
599726
// ============================================================================
@@ -1083,6 +1210,8 @@ fn test_performance_summary() {
10831210
let mut startup_stats = StatisticalMetrics::new();
10841211
let mut refresh_stats = StatisticalMetrics::new();
10851212
let mut time_to_first_env_stats = StatisticalMetrics::new();
1213+
let mut phase_stats = BTreeMap::new();
1214+
let mut locator_stats = BTreeMap::new();
10861215
let mut env_count = 0usize;
10871216
let mut manager_count = 0usize;
10881217

@@ -1120,6 +1249,11 @@ fn test_performance_summary() {
11201249
if let Some(ttfe) = client.time_to_first_env() {
11211250
time_to_first_env_stats.add(ttfe.as_millis());
11221251
}
1252+
collect_refresh_progress(
1253+
&client.get_refresh_progress(),
1254+
&mut phase_stats,
1255+
&mut locator_stats,
1256+
);
11231257

11241258
println!(
11251259
" Iteration {}: startup={}ms, refresh={}ms, envs={}",
@@ -1130,6 +1264,21 @@ fn test_performance_summary() {
11301264
);
11311265
}
11321266

1267+
for phase in ["locators", "path", "globalVirtualEnvs", "workspaces"] {
1268+
let count = phase_stats
1269+
.get(phase)
1270+
.map(StatisticalMetrics::count)
1271+
.unwrap_or_default();
1272+
assert_eq!(
1273+
count, STAT_ITERATIONS,
1274+
"Expected one completed {phase} phase per refresh iteration"
1275+
);
1276+
}
1277+
assert!(
1278+
!locator_stats.is_empty(),
1279+
"Expected per-locator timing in RefreshProgress telemetry"
1280+
);
1281+
11331282
// Print statistical summary
11341283
println!("\n----------------------------------------");
11351284
println!(" STATISTICS ");
@@ -1139,10 +1288,19 @@ fn test_performance_summary() {
11391288
if time_to_first_env_stats.count() > 0 {
11401289
time_to_first_env_stats.print_summary("Time to first env");
11411290
}
1291+
for (phase, metrics) in &phase_stats {
1292+
metrics.print_summary(&format!("Phase {phase}"));
1293+
}
1294+
for (locator, metrics) in &locator_stats {
1295+
metrics.print_summary(&format!("Locator {locator}"));
1296+
}
11421297
println!("Environments found: {}", env_count);
11431298
println!("Managers found: {}", manager_count);
11441299
println!("========================================\n");
11451300

1301+
let phase_json = statistics_json(&phase_stats);
1302+
let locator_json = statistics_json(&locator_stats);
1303+
11461304
// Output as JSON for CI parsing
11471305
// Includes both P50 values at top level (for backwards compatibility) and full stats
11481306
let json_output = serde_json::to_string_pretty(&json!({
@@ -1155,7 +1313,9 @@ fn test_performance_summary() {
11551313
"server_startup": startup_stats.to_json(),
11561314
"full_refresh": refresh_stats.to_json(),
11571315
"time_to_first_env": time_to_first_env_stats.to_json()
1158-
}
1316+
},
1317+
"phases": phase_json,
1318+
"locators": locator_json
11591319
}))
11601320
.unwrap();
11611321

0 commit comments

Comments
 (0)