From 397f0049ef271d8b4f3a0c046b6cf041f5328f19 Mon Sep 17 00:00:00 2001 From: lancelui-amzn Date: Wed, 22 Apr 2026 12:20:36 -0500 Subject: [PATCH] Replace GraphData for ProfilingData --- .../profile_metadata_comparison_rule.rs | 20 +-- .../profile_metadata_expected_rule.rs | 6 +- .../profile_stack_frame_threshold_rule.rs | 20 +-- src/analytics/rules/flamegraphs.rs | 2 +- src/analytics/rules/java_profile.rs | 2 +- src/data/common/data_formats.rs | 77 +++----- src/data/common/processed_data_accessor.rs | 6 +- src/data/flamegraphs.rs | 47 ++--- src/data/hotline.rs | 24 +-- src/data/java_profile.rs | 139 +++++++-------- src/profiling/jfr/convert.rs | 63 ++++--- src/profiling/jfr/mod.rs | 6 +- src/profiling/mod.rs | 165 +++++++++++++++++- src/report_frontend/src/components/Report.tsx | 4 +- .../src/components/data/IframeGraph.tsx | 13 +- .../src/components/data/KeyValueTable.tsx | 61 +++++++ .../src/components/pages/GraphDataPage.tsx | 137 --------------- .../src/components/pages/KeyValueDataPage.tsx | 74 +------- .../components/pages/ProfilingDataPage.tsx | 142 +++++++++++++++ src/report_frontend/src/definitions/types.ts | 20 ++- .../test_profile_metadata_comparison_rule.rs | 72 ++++---- .../test_profile_metadata_expected_rule.rs | 63 ++++--- ...test_profile_stack_frame_threshold_rule.rs | 63 ++++--- tests/test_java_profile.rs | 160 +++++++---------- 24 files changed, 754 insertions(+), 632 deletions(-) create mode 100644 src/report_frontend/src/components/data/KeyValueTable.tsx delete mode 100644 src/report_frontend/src/components/pages/GraphDataPage.tsx create mode 100644 src/report_frontend/src/components/pages/ProfilingDataPage.tsx diff --git a/src/analytics/rule_templates/profile_metadata_comparison_rule.rs b/src/analytics/rule_templates/profile_metadata_comparison_rule.rs index 11f5113b..f4910557 100644 --- a/src/analytics/rule_templates/profile_metadata_comparison_rule.rs +++ b/src/analytics/rule_templates/profile_metadata_comparison_rule.rs @@ -73,12 +73,12 @@ impl Analyze for ProfileMetadataComparisonRule { // Map base values from all profiles in the first record let base_values: HashMap = - if let AperfData::Graph(graph_data) = base_run_data { - graph_data - .profiler_data_map + if let AperfData::Profile(profiling_data) = base_run_data { + profiling_data + .profilers .iter() - .filter_map(|(key, profiler_data)| { - profiler_data + .filter_map(|(key, profiler)| { + profiler .metadata .key_value_groups .get(self.group) @@ -114,15 +114,15 @@ impl Analyze for ProfileMetadataComparisonRule { continue; } - let AperfData::Graph(graph_data) = run_data else { + let AperfData::Profile(profiling_data) = run_data else { continue; }; - let comparison_values: HashMap = graph_data - .profiler_data_map + let comparison_values: HashMap = profiling_data + .profilers .iter() - .filter_map(|(key, profiler_data)| { - profiler_data + .filter_map(|(key, profiler)| { + profiler .metadata .key_value_groups .get(self.group) diff --git a/src/analytics/rule_templates/profile_metadata_expected_rule.rs b/src/analytics/rule_templates/profile_metadata_expected_rule.rs index 6e7c40ef..4c79febf 100644 --- a/src/analytics/rule_templates/profile_metadata_expected_rule.rs +++ b/src/analytics/rule_templates/profile_metadata_expected_rule.rs @@ -69,11 +69,11 @@ impl Analyze for ProfileMetadataExpectedRule { }; for (run_name, run_data) in &processed_data.runs { - let AperfData::Graph(graph_data) = run_data else { + let AperfData::Profile(profiling_data) = run_data else { continue; }; - for (key, profiler_data) in &graph_data.profiler_data_map { - let metadata_value = profiler_data + for (key, profiler) in &profiling_data.profilers { + let metadata_value = profiler .metadata .key_value_groups .get(self.group) diff --git a/src/analytics/rule_templates/profile_stack_frame_threshold_rule.rs b/src/analytics/rule_templates/profile_stack_frame_threshold_rule.rs index 118a0e30..9ce1c50a 100644 --- a/src/analytics/rule_templates/profile_stack_frame_threshold_rule.rs +++ b/src/analytics/rule_templates/profile_stack_frame_threshold_rule.rs @@ -14,7 +14,7 @@ use std::fmt::Formatter; /// threshold - % samples to generate finding pub struct ProfileStackFrameThresholdRule { pub rule_name: &'static str, - pub graph_group: &'static str, + pub profile_type: &'static str, pub stack_frame: &'static [&'static [&'static str]], pub frame_type: Option, pub thread_states: &'static [ThreadState], @@ -28,7 +28,7 @@ pub struct ProfileStackFrameThresholdRule { macro_rules! profile_stack_frame_threshold { { name: $rule_name:literal, - graph_group: $graph_group:literal, + profile_type: $profile_type:literal, stack_frame: [$([$($frame:literal),+]),+], frame_type: $frame_type:expr, $(thread_states: [$($state:expr),*],)? @@ -41,7 +41,7 @@ macro_rules! profile_stack_frame_threshold { AnalyticalRule::ProfileStackFrameThresholdRule( ProfileStackFrameThresholdRule { rule_name: $rule_name, - graph_group: $graph_group, + profile_type: $profile_type, stack_frame: &[$(&[$($frame),+]),+], frame_type: $frame_type, thread_states: &[$($($state),*)?], @@ -70,23 +70,23 @@ impl Analyze for ProfileStackFrameThresholdRule { _processed_data_accessor: &mut ProcessedDataAccessor, ) { for (run_name, run_data) in &processed_data.runs { - let AperfData::Graph(graph_data) = run_data else { + let AperfData::Profile(profiling_data) = run_data else { continue; }; - for (key, profiler_data) in &graph_data.profiler_data_map { - if !profiler_data.profiles.contains_key(self.graph_group) { + for (key, profiler) in &profiling_data.profilers { + if !profiler.profiles.contains_key(self.profile_type) { continue; } let total_samples = - profiler_data.get_total_samples(self.graph_group, self.thread_states); + profiler.get_total_samples(self.profile_type, self.thread_states); let (sample_count, matched_pattern) = self.stack_frame .iter() .fold((0u64, None), |(acc, acc_pat), pattern| { - let count = profiler_data.get_samples( - self.graph_group, + let count = profiler.get_samples( + self.profile_type, pattern, self.frame_type, self.thread_states, @@ -120,7 +120,7 @@ impl Analyze for ProfileStackFrameThresholdRule { }; let finding_description = format!( "Stack pattern {} accounts for {:.2}% of samples in {} (threshold: {:.2}%)", - pattern_str, percentage, self.graph_group, self.threshold + pattern_str, percentage, self.profile_type, self.threshold ); report_findings.insert_finding( diff --git a/src/analytics/rules/flamegraphs.rs b/src/analytics/rules/flamegraphs.rs index d51041d7..8390b1d4 100644 --- a/src/analytics/rules/flamegraphs.rs +++ b/src/analytics/rules/flamegraphs.rs @@ -7,7 +7,7 @@ impl AnalyzeData for Flamegraph { fn get_analytical_rules(&self) -> Vec { vec![profile_stack_frame_threshold! { name: "Place Holder", - graph_group: "default", + profile_type: "default", stack_frame: [["place_holder_frame"]], frame_type: None, thread_states: [], diff --git a/src/analytics/rules/java_profile.rs b/src/analytics/rules/java_profile.rs index d62c9ca1..fd42e2ee 100644 --- a/src/analytics/rules/java_profile.rs +++ b/src/analytics/rules/java_profile.rs @@ -16,7 +16,7 @@ impl AnalyzeData for JavaProfile { // If rule for cpu or alloc group, use only ThreadState::AsyncDefault profile_stack_frame_threshold! { name: "Excessive Exceptions", - graph_group: "cpu", + profile_type: "cpu", stack_frame: [[r"java\.lang\.(Throwable\.(fillInStackTrace|getStackTrace|getOurStackTrace)|StackTraceElement\.)|\w+(\.\w+)*\.((\w*Exception|\w*Error|Throwable)\.)"]], frame_type: None, thread_states: [ThreadState::AsyncDefault], diff --git a/src/data/common/data_formats.rs b/src/data/common/data_formats.rs index 5af79feb..6b1d3197 100644 --- a/src/data/common/data_formats.rs +++ b/src/data/common/data_formats.rs @@ -17,7 +17,7 @@ pub enum DataFormat { TimeSeries, Text, KeyValue, - Graph, + Profile, Unknown, } @@ -45,7 +45,7 @@ pub enum AperfData { TimeSeries(TimeSeriesData), Text(TextData), KeyValue(KeyValueData), - Graph(GraphData), + Profile(ProfilingData), } impl AperfData { @@ -54,7 +54,7 @@ impl AperfData { AperfData::TimeSeries(_) => DataFormat::TimeSeries, AperfData::Text(_) => DataFormat::Text, AperfData::KeyValue(_) => DataFormat::KeyValue, - AperfData::Graph(_) => DataFormat::Graph, + AperfData::Profile(_) => DataFormat::Profile, } } } @@ -152,47 +152,6 @@ pub struct KeyValueGroup { pub key_values: HashMap, } -// ------------------------------------------ GRAPH DATA ------------------------------------------- -/// Data types falling into this format produce one or more HTML or SVG files at the end of a -/// recording run, which are to be rendered through IFrame in the report. The graphs can be -/// categorized into different groups, so that only one group of graphs are shown in the report -/// at a time. -#[derive(Serialize, Deserialize, Debug, Default, Clone)] -pub struct GraphData { - pub graph_groups: Vec, - pub profiler_data_map: HashMap, -} - -/// Contents of a graph group, which contains all graphs to be displayed together. -#[derive(Serialize, Deserialize, Debug, Default, Clone)] -pub struct GraphGroup { - /// Name of the graph group. - pub group_name: String, - /// A map from graph names to all graphs within the group. - pub graphs: HashMap, -} - -/// Information about a graph. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Graph { - /// The name of the graph. - pub graph_name: String, - /// The relative path to graph (value of the IFrame's src attribute). - pub graph_path: String, - /// The size of the graph, which can be used for graph ordering in the report. - pub graph_size: Option, -} - -impl Graph { - pub fn new(graph_name: String, graph_path: String, graph_size: Option) -> Self { - Graph { - graph_name, - graph_path, - graph_size, - } - } -} - // ------------------------------------------- TEXT DATA ------------------------------------------- /// Data types falling into this format produce human-readable, formatted, texts, which can be /// displayed in the report directly. @@ -202,14 +161,24 @@ pub struct TextData { pub lines: Vec, } -// ---------------------------------------- PROFILER DATA ------------------------------------------ -/// Time-bucketed profiling data supporting JFR and perf record sources. -/// Enables per-block sample lookups (for heatmaps and time range selection) and call graph traversal -/// (for self/total sample computation). -/// -/// See [`crate::profiling`] for the implementation of profile building and querying. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProfilerData { +// ---------------------------------------- PROFILING DATA ------------------------------------------ +/// Data types falling into this format collect profiling data from one or more profiled +/// targets (e.g. JVMs for java profiling, and system for perf profiling). Each target is +/// represented by a [`Profiler`] holding metadata and a map of [`Profile`]s keyed by profiling +/// type (e.g. "cpu", "wall", "allocation"). Each [`Profile`] carries: +/// - Time bucketed sample data with total counts for the entire profile +/// - Calling context tree used to analyze a selected time range +/// - A [`ProfileGraph`](crate::profiling::ProfileGraph) pointing to a pre-rendered HTML/SVG +/// file displayed via IFrame in the report (legacy path, to be removed once native +/// rendering is complete). +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ProfilingData { + /// Map from profiler name to its profiler data + pub profilers: HashMap, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Profiler { /// Start time of the profile in milliseconds since epoch pub start_time_ms: i64, /// Duration of each block in milliseconds @@ -220,9 +189,9 @@ pub struct ProfilerData { pub profiles: HashMap, } -impl ProfilerData { +impl Profiler { pub fn new(start_time_ms: i64, block_width_ms: u64) -> Self { - ProfilerData { + Profiler { start_time_ms, block_width_ms, metadata: KeyValueData::default(), diff --git a/src/data/common/processed_data_accessor.rs b/src/data/common/processed_data_accessor.rs index cc322a6c..9892c847 100644 --- a/src/data/common/processed_data_accessor.rs +++ b/src/data/common/processed_data_accessor.rs @@ -130,7 +130,7 @@ impl ProcessedDataAccessor { DataFormat::TimeSeries => self.time_series_data_json_string(processed_data), DataFormat::KeyValue => self.key_value_data_json_string(processed_data), DataFormat::Text => self.text_data_json_string(processed_data), - DataFormat::Graph => self.graph_data_json_string(processed_data), + DataFormat::Profile => self.profiler_data_json_string(processed_data), DataFormat::Unknown => serde_json::to_string(processed_data).unwrap(), } } @@ -228,8 +228,8 @@ impl ProcessedDataAccessor { serde_json::to_string(processed_data).unwrap() } - /// Serializes the processed graph data into JSON string. - pub fn graph_data_json_string(&mut self, processed_data: &ProcessedData) -> String { + /// Serializes the processed profiler data into JSON string. + pub fn profiler_data_json_string(&mut self, processed_data: &ProcessedData) -> String { serde_json::to_string(processed_data).unwrap() } diff --git a/src/data/flamegraphs.rs b/src/data/flamegraphs.rs index 42683074..02073102 100644 --- a/src/data/flamegraphs.rs +++ b/src/data/flamegraphs.rs @@ -1,5 +1,6 @@ -use crate::data::common::data_formats::{AperfData, Graph, GraphData, GraphGroup}; +use crate::data::common::data_formats::{AperfData, Profiler, ProfilingData}; use crate::data::{Data, ProcessData}; +use crate::profiling::{Profile, ProfileGraph}; use crate::visualizer::ReportParams; use anyhow::Result; use log::error; @@ -85,7 +86,7 @@ impl CollectData for FlamegraphRaw { } Ok(_) => { info!("Creating flamegraph..."); - // TODO: extract metadata from perf record and generate script -> ProfilerData + // TODO: extract metadata from perf record and generate script -> ProfilingData let script_loc = data_dir.join("script.out"); let out = Command::new("perf") .stdout(File::create(&script_loc)?) @@ -99,7 +100,7 @@ impl CollectData for FlamegraphRaw { } Ok(_) => { let collapse_loc = data_dir.join("collapse.out"); - // TODO: move flamegraph generation to report phase using ProfilerData (so user specifies time range) + // TODO: move flamegraph generation to report phase using ProfilingData (so user specifies time range) Folder::default().collapse_file( Some(script_loc.clone()), File::create(&collapse_loc)?, @@ -158,11 +159,12 @@ impl ProcessData for Flamegraph { params: ReportParams, _raw_data: Vec, ) -> Result { - fn copy_and_add_to_graph_group( + fn copy_and_add_to_profiler( params: &ReportParams, filename: String, - graph_data: &mut GraphData, - graph_group_name: String, + profiling_data: &mut ProfilingData, + profiler_name: String, + profile_name: String, ) { let source_path = params.data_dir.join(&filename); let relative_dest_path = PathBuf::from("data/js").join(filename); @@ -170,38 +172,39 @@ impl ProcessData for Flamegraph { if source_path.exists() { if let Ok(_) = std::fs::copy(&source_path, &dest_path) { - let mut graph_group = GraphGroup::default(); - graph_group.group_name = graph_group_name.clone(); - graph_group.graphs.insert( - String::new(), - Graph::new( - format!("Kernel Profiling Flamegraph ({graph_group_name})"), + let profiler = profiling_data + .profilers + .entry(profiler_name.clone()) + .or_insert_with(Profiler::default); + profiler.profiles.insert( + profile_name.clone(), + Profile::with_graph(ProfileGraph::new( + format!("Kernel Profiling Flamegraph ({profiler_name})"), relative_dest_path.into_os_string().into_string().unwrap(), None, - ), + )), ); - graph_data.graph_groups.push(graph_group); } } } - let mut graph_data = GraphData::default(); + let mut profiling_data = ProfilingData::default(); - // TODO: Populate graph_data profiler_data_map from record serialized profiler data - - copy_and_add_to_graph_group( + copy_and_add_to_profiler( ¶ms, format!("{}-flamegraph.svg", params.run_name), - &mut graph_data, + &mut profiling_data, + String::from("perf"), String::from("default"), ); - copy_and_add_to_graph_group( + copy_and_add_to_profiler( ¶ms, format!("{}-reverse-flamegraph.svg", params.run_name), - &mut graph_data, + &mut profiling_data, + String::from("perf"), String::from("reverse"), ); - Ok(AperfData::Graph(graph_data)) + Ok(AperfData::Profile(profiling_data)) } } diff --git a/src/data/hotline.rs b/src/data/hotline.rs index 3bf51d4d..05ecd7cb 100644 --- a/src/data/hotline.rs +++ b/src/data/hotline.rs @@ -1,7 +1,8 @@ extern crate ctor; -use crate::data::common::data_formats::{AperfData, Graph, GraphData, GraphGroup}; +use crate::data::common::data_formats::{AperfData, Profiler, ProfilingData}; use crate::data::ProcessData; +use crate::profiling::{Profile, ProfileGraph}; use crate::{data::Data, visualizer::ReportParams}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -288,7 +289,7 @@ impl ProcessData for Hotline { ) -> Result { use crate::data::hotline::hotline_reports::REPORT_CONFIGS; - let mut graph_data = GraphData::default(); + let mut profiling_data = ProfilingData::default(); for config in REPORT_CONFIGS { let csv_string = fs::read_to_string(params.data_dir.join(config.filename))?; @@ -321,20 +322,21 @@ impl ProcessData for Hotline { let mut file = File::create(dest_path)?; file.write_all(full_html.as_bytes())?; - let mut graph_group = GraphGroup::default(); - graph_group.group_name = config.table_id.to_string(); - graph_group.graphs.insert( - String::new(), - Graph::new( + // TODO: Create standard data format for hotline (and similar) data + let profiler = profiling_data + .profilers + .entry(config.table_id.to_string()) + .or_insert_with(Profiler::default); + profiler.profiles.insert( + config.table_id.to_string(), + Profile::with_graph(ProfileGraph::new( format!("{}_table", config.table_id), relative_dest_path.into_os_string().into_string().unwrap(), None, - ), + )), ); - - graph_data.graph_groups.push(graph_group); } - Ok(AperfData::Graph(graph_data)) + Ok(AperfData::Profile(profiling_data)) } } diff --git a/src/data/java_profile.rs b/src/data/java_profile.rs index 3e97788d..023d57fe 100644 --- a/src/data/java_profile.rs +++ b/src/data/java_profile.rs @@ -1,8 +1,8 @@ -use crate::data::common::data_formats::{AperfData, Graph, GraphData, GraphGroup, ProfilerData}; +use crate::data::common::data_formats::{AperfData, Profiler, ProfilingData}; #[cfg(target_os = "linux")] use crate::data::common::utils::get_data_name_from_type; use crate::data::{Data, ProcessData}; -use crate::profiling::{jfr, BUCKET_WIDTH_MS}; +use crate::profiling::{jfr, Profile, ProfileGraph, BUCKET_WIDTH_MS}; use crate::visualizer::ReportParams; use anyhow::Result; use log::error; @@ -338,20 +338,20 @@ impl CollectData for JavaProfileRaw { } } - // Generate ProfilerData from JFR + // Generate Profiler from JFR let profiler_data_path = params.data_dir.join(format!( "{}-java-profile-{}-profiler-data.json", params.run_name, key )); - match jfr::jfr_to_profiler_data(&jfr_path, BUCKET_WIDTH_MS) { - Ok(mut profiler_data) => { - profiler_data.metadata = jfr::parse_jfr_metadata(&metadata_json); - if let Ok(json) = serde_json::to_string(&profiler_data) { + match jfr::jfr_to_profiler(&jfr_path, BUCKET_WIDTH_MS) { + Ok(mut profiler) => { + profiler.metadata = jfr::parse_jfr_metadata(&metadata_json); + if let Ok(json) = serde_json::to_string(&profiler) { fs::write(&profiler_data_path, json).ok(); } } Err(e) => { - error!("Failed to build ProfilerData for {}: {}", key, e); + error!("Failed to build Profiler Data for {}: {}", key, e); } } @@ -391,7 +391,7 @@ impl ProcessData for JavaProfile { params: ReportParams, _raw_data: Vec, ) -> Result { - let mut graph_data = GraphData::default(); + let mut profiling_data = ProfilingData::default(); let processes_loc = params .data_dir @@ -399,25 +399,23 @@ impl ProcessData for JavaProfile { let processes_json = fs::read_to_string(processes_loc.to_str().unwrap()).unwrap_or_default(); let process_map: HashMap> = - serde_json::from_str(&processes_json).unwrap_or(HashMap::new()); + serde_json::from_str(&processes_json).unwrap_or_default(); let mut profile_metrics = Vec::from(PROFILE_METRICS); profile_metrics.push("legacy"); - // There might be multiple JVMs with the same name. Use the number of occurrences - // for each JVM name to dedupe + // Track JVMs with same name let mut jvm_name_counts: HashMap = HashMap::new(); - // Stores the deduped JVM name of each PID - let mut deduped_names: HashMap = HashMap::new(); - - for metric in profile_metrics { - let mut graph_group = GraphGroup::default(); - graph_group.group_name = String::from(metric); - - for (process, process_names) in &process_map { - let filename = if metric == "legacy" { - // backward compatibility - to support previous versions where java profile - // generates a single flamegraph + let relative_path = PathBuf::from("data/js"); + let report_dest_dir = params.report_dir.join(&relative_path); + + for (process, process_names) in &process_map { + // TODO: Remove when natively rendering profile data. Holds jfrconv-generated + // html graphs for this process, keyed by metric name. + let mut jfrconv_graphs: HashMap = HashMap::new(); + for metric in &profile_metrics { + let filename = if *metric == "legacy" { + // backward compatibility - previous versions generated a single flamegraph format!("{}-java-flamegraph-{}.html", params.run_name, process) } else { format!( @@ -425,69 +423,56 @@ impl ProcessData for JavaProfile { params.run_name, process, metric ) }; - - let relative_path = PathBuf::from("data/js"); - if let Some(file_size) = copy_file_to_report_data( - &filename, - ¶ms.data_dir, - ¶ms.report_dir.join(relative_path.clone()), - ) { - let jvm_name = process_names.first().map_or("unknown", |s| s.as_str()); - - // Dedupe JVM names - if !deduped_names.contains_key(process) { - let jvm_name_count = - jvm_name_counts.entry(jvm_name.to_string()).or_insert(0); - *jvm_name_count += 1; - deduped_names.insert( - process.clone(), - if *jvm_name_count > 1 { - format!("{} ({})", jvm_name, *jvm_name_count - 1) - } else { - jvm_name.to_string() - }, - ); - } - - let graph_name = - format!("({}) JVM: {}", metric, deduped_names.get(process).unwrap()); - - graph_group.graphs.insert( - graph_name.clone(), - Graph::new( - graph_name, - relative_path - .join(filename) - .into_os_string() - .into_string() - .unwrap(), - Some(file_size), - ), - ); - } + let Some(file_size) = + copy_file_to_report_data(&filename, ¶ms.data_dir, &report_dest_dir) + else { + continue; + }; + let graph_path = relative_path + .join(&filename) + .into_os_string() + .into_string() + .unwrap(); + jfrconv_graphs.insert( + metric.to_string(), + ProfileGraph::new(String::new(), graph_path, Some(file_size)), + ); + } + if jfrconv_graphs.is_empty() { + continue; } - graph_data.graph_groups.push(graph_group); - } - - // Load profiler data for Java profiles - for (process, _) in &process_map { + // Assign a deduped JVM name for this process + let jvm_name = process_names.first().map_or("unknown", |s| s.as_str()); + let count = jvm_name_counts.entry(jvm_name.to_string()).or_insert(0); + *count += 1; + let deduped_name = if *count > 1 { + format!("{} ({})", jvm_name, *count - 1) + } else { + jvm_name.to_string() + }; + + // Load profiler JSON if present, attach jfrconv graphs. let profiler_data_path = params.data_dir.join(format!( "{}-java-profile-{}-profiler-data.json", params.run_name, process )); - if let Ok(json) = fs::read_to_string(&profiler_data_path) { - if let Ok(profiler_data) = serde_json::from_str::(&json) { - if let Some(name) = deduped_names.get(process) { - graph_data - .profiler_data_map - .insert(name.clone(), profiler_data); - } - } + let mut profiler = fs::read_to_string(&profiler_data_path) + .ok() + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or_default(); + for (metric, mut graph) in jfrconv_graphs { + graph.graph_name = format!("({}) JVM: {}", metric, deduped_name); + profiler + .profiles + .entry(metric) + .or_insert_with(Profile::new) + .profile_graph = graph; } + profiling_data.profilers.insert(deduped_name, profiler); } - Ok(AperfData::Graph(graph_data)) + Ok(AperfData::Profile(profiling_data)) } } diff --git a/src/profiling/jfr/convert.rs b/src/profiling/jfr/convert.rs index 50702175..a48c05ef 100644 --- a/src/profiling/jfr/convert.rs +++ b/src/profiling/jfr/convert.rs @@ -1,4 +1,4 @@ -use crate::data::common::data_formats::{KeyValueData, ProfilerData}; +use crate::data::common::data_formats::{KeyValueData, Profiler}; use crate::profiling::jfr::{ExecSampleType, JfrEvent, JfrReader}; use crate::profiling::{FrameType, ThreadState}; use anyhow::Result; @@ -280,8 +280,8 @@ fn parse_type(chars: &mut std::iter::Peekable) -> String { } /// This function uses JfrReader to parse async-profiler generated JFR files into -/// APerf ProfilerData. -pub fn jfr_to_profiler_data(path: &Path, block_width_ms: u64) -> Result { +/// APerf Profiler. +pub fn jfr_to_profiler(path: &Path, block_width_ms: u64) -> Result { let mut reader = JfrReader::open(path.to_str().unwrap())?; let start_time_ms = reader.start_nanos / 1_000_000; @@ -301,7 +301,12 @@ pub fn jfr_to_profiler_data(path: &Path, block_width_ms: u64) -> Result formatted frame string + let mut frame_cache: HashMap<(i64, u8), String> = HashMap::new(); + // Cache: class_id -> formatted alloc class string + let mut alloc_class_cache: HashMap = HashMap::new(); loop { match reader.read_event() { @@ -338,28 +343,46 @@ pub fn jfr_to_profiler_data(path: &Path, block_width_ms: u64) -> Result Result node_id (index into context_tree) -> sample count] pub blocks: Vec>>, /// Block number time range where profile node aggregate counts are calculated @@ -30,6 +33,27 @@ pub struct Profile { total_samples_per_thread_state: OnceCell>, } +/// Information about a graph. TODO: Will remove after native profiler visualization is implemented +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct ProfileGraph { + /// The name of the graph. + pub graph_name: String, + /// The relative path to graph (value of the IFrame's src attribute). + pub graph_path: String, + /// The size of the graph, which can be used for graph ordering in the report. + pub graph_size: Option, +} + +impl ProfileGraph { + pub fn new(graph_name: String, graph_path: String, graph_size: Option) -> Self { + ProfileGraph { + graph_name, + graph_path, + graph_size, + } + } +} + /// A node in the call tree. Each node represents a unique call path. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CCTreeNode { @@ -52,7 +76,7 @@ pub struct SampleStats { } /// Frame id to name bidirectional mapping, with name→index lookup for insertion. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct FrameMap { frame_id_to_frame: Vec, #[serde(skip)] @@ -108,8 +132,8 @@ impl FrameMap { } } -// Public functions for building ProfilerData -impl ProfilerData { +// Public functions for building Profiler +impl Profiler { /// Insert a stack sample into the appropriate profile and time block. /// /// Computes the block index from `sample_time_ms` relative to `self.start_time` @@ -125,7 +149,7 @@ impl ProfilerData { let profile = self .profiles .entry(profile_type.to_string()) - .or_insert_with(Profile::new); + .or_insert_with(|| Profile::new()); profile.insert_stack( sample_time_ms, self.start_time_ms, @@ -158,6 +182,31 @@ impl ProfilerData { .get(profile_type) .map_or(0, |p| p.get_total_samples(thread_states)) } + + /// Generate the collapsed format of the current context tree, which has the aggregated sample + /// counts for Profile.time_range. + pub fn generate_collapsed(&self, profile_type: &str, thread_states: &[ThreadState]) -> String { + match self.profiles.get(profile_type) { + Some(profile) => profile.generate_collapsed(thread_states), + None => String::new(), + } + } + + /// Update context_tree sample_counts to contain the aggregate of samples between the specified + /// start and end times. + pub fn set_time_range( + &mut self, + relative_start_time_ms: u64, + relative_end_time_ms: u64, + ) -> Result<()> { + let start_idx = (relative_start_time_ms / self.block_width_ms) as usize; + let end_idx = (relative_end_time_ms / self.block_width_ms) as usize; + + for (_profile_type, profile) in self.profiles.iter_mut() { + profile.set_time_range(start_idx, end_idx)?; + } + Ok(()) + } } impl Profile { @@ -165,6 +214,7 @@ impl Profile { let mut frame_map = FrameMap::new(); frame_map.get_or_insert("[root]"); Self { + profile_graph: ProfileGraph::default(), blocks: Vec::new(), time_range: (0, 0), context_tree: vec![CCTreeNode { @@ -178,6 +228,12 @@ impl Profile { } } + pub fn with_graph(profile_graph: ProfileGraph) -> Self { + let mut p = Self::new(); + p.profile_graph = profile_graph; + p + } + /// Returns sum of sample counts for call graph nodes matching a stack pattern. /// Pattern frames must appear in order walking from root to leaf (i.e., parent→child order). /// Example: pattern ["A", "C"] matches nodes named "C" that have an ancestor named "A". @@ -192,7 +248,7 @@ impl Profile { thread_states: &[ThreadState], total_samples: bool, ) -> u64 { - if pattern.is_empty() { + if pattern.is_empty() || self.context_tree.is_empty() { return 0; } @@ -377,6 +433,101 @@ impl Profile { .collect() } } + + /// DFS through call tree, print current path if node self samples is > 0. + /// collapsed format consists lines of call stack and sample count: + /// + /// frame1;frame2;frame3 10 + /// frame1;frame4 20 + pub fn generate_collapsed(&self, thread_states: &[ThreadState]) -> String { + let thread_state_ids = self.resolve_thread_states(thread_states); + let mut result = String::new(); + let mut path: Vec = Vec::new(); + self.dfs_collapsed(0, &thread_state_ids, &mut path, &mut result); + result + } + + fn dfs_collapsed( + &self, + node_id: usize, + thread_state_ids: &[u8], + path: &mut Vec, + result: &mut String, + ) { + let node = &self.context_tree[node_id]; + + // Emit line if this node has self_samples for any requested thread state + if !path.is_empty() { + let self_samples: u64 = thread_state_ids + .iter() + .filter_map(|ts| node.sample_stats.get(ts).map(|s| s.self_samples)) + .sum(); + if self_samples > 0 { + let stack: String = path + .iter() + .map(|&nid| self.frame_map.name(self.context_tree[nid].frame_id)) + .collect::>() + .join(";"); + result.push_str(&format!("{} {}\n", stack, self_samples)); + } + } + + for (&_frame_id, &child_node_id) in &node.children { + path.push(child_node_id); + self.dfs_collapsed(child_node_id, thread_state_ids, path, result); + path.pop(); + } + } + + /// Iterates through the corresponding time blocks in Profile.blocks and + /// accumulates the sample counts in nodes. Counts are accumulated to a nodes self_samples + /// and every of its ancestors total samples. Then Profile.time_range is updated. + pub fn set_time_range(&mut self, start_idx: usize, end_idx: usize) -> Result<()> { + // Clear existing sample stats + for node in &mut self.context_tree { + node.sample_stats.clear(); + } + + // Reset cached totals + self.total_samples_per_thread_state = OnceCell::new(); + + let end = end_idx.min(self.blocks.len()); + for block_idx in start_idx..end { + for (&thread_state_id, node_map) in &self.blocks[block_idx] { + for (&node_id, &count) in node_map { + // Add self_samples to the leaf node + self.context_tree[node_id] + .sample_stats + .entry(thread_state_id) + .or_insert(SampleStats { + total_samples: 0, + self_samples: 0, + }) + .self_samples += count; + + // Walk up ancestors adding total_samples + let mut cur = node_id; + loop { + self.context_tree[cur] + .sample_stats + .entry(thread_state_id) + .or_insert(SampleStats { + total_samples: 0, + self_samples: 0, + }) + .total_samples += count; + match self.context_tree[cur].parent { + Some(parent) => cur = parent, + None => break, + } + } + } + } + } + + self.time_range = (start_idx, end_idx); + Ok(()) + } } #[derive(Debug, Clone, Copy)] diff --git a/src/report_frontend/src/components/Report.tsx b/src/report_frontend/src/components/Report.tsx index a2909011..59b79804 100644 --- a/src/report_frontend/src/components/Report.tsx +++ b/src/report_frontend/src/components/Report.tsx @@ -9,7 +9,7 @@ import { ReportHelpPanel } from "./misc/ReportHelpPanel"; import { NumCpusPerRun, SelectedCpusPerRun } from "../definitions/types"; import TimeSeriesDataPage from "./pages/TimeSeriesDataPage"; import KeyValueDataPage from "./pages/KeyValueDataPage"; -import GraphDataPage from "./pages/GraphDataPage"; +import ProfilingDataPage from "./pages/ProfilingDataPage"; import TextDataPage from "./pages/TextDataPage"; import ReportHomePage from "./pages/ReportHomePage"; import { MAX_NUM_CPU_SHOW_DEFAULT } from "../definitions/constants"; @@ -110,7 +110,7 @@ export default function () { {!preprocessing && dataFormat == "key_value" && ( )} - {!preprocessing && dataFormat == "graph" && } + {!preprocessing && dataFormat == "profile" && } {!preprocessing && dataFormat == "text" && } {!preprocessing && (dataFormat == "unknown" || (dataFormat as string) === "" || dataFormat === undefined) && ( diff --git a/src/report_frontend/src/components/data/IframeGraph.tsx b/src/report_frontend/src/components/data/IframeGraph.tsx index a72d418a..5dedbb7e 100644 --- a/src/report_frontend/src/components/data/IframeGraph.tsx +++ b/src/report_frontend/src/components/data/IframeGraph.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { DataType, GraphData, GraphInfo } from "../../definitions/types"; +import { DataType, ProfilingData, GraphInfo } from "../../definitions/types"; import { PROCESSED_DATA } from "../../definitions/data-config"; import { Container, Icon, Link } from "@cloudscape-design/components"; import Header from "@cloudscape-design/components/header"; @@ -7,18 +7,17 @@ import Header from "@cloudscape-design/components/header"; export interface IframeGraphProps { readonly dataType: DataType; readonly runName: string; - readonly graphGroup: string; + readonly profilerName: string; readonly graphName: string; } export default function (props: IframeGraphProps) { - const graphData: GraphData | undefined = PROCESSED_DATA[props.dataType].runs[props.runName] as GraphData; - const graphInfo: GraphInfo | undefined = graphData?.graph_groups.find( - (graph_group) => graph_group.group_name == props.graphGroup, - )?.graphs[props.graphName]; + const profilingData: ProfilingData | undefined = PROCESSED_DATA[props.dataType].runs[props.runName] as ProfilingData; + const graphInfo: GraphInfo | undefined = + profilingData?.profilers?.[props.profilerName]?.profiles?.[props.graphName]?.profile_graph; if (!graphInfo) { - return This data was not collected in the APerf run.; + return This graph was not collected in this APerf run.; } else { return ( ) { + let isDummySection = true; + const keyValueTableItems = new Map(); + + for (const [runName, data] of dataByRun) { + if (!data?.key_value_groups) continue; + for (const groupName in data.key_value_groups) { + if (groupName !== "") isDummySection = false; + for (const [key, value] of Object.entries(data.key_value_groups[groupName].key_values)) { + const tableItemsKey = `${groupName} ${key}`.toLowerCase(); + if (!keyValueTableItems.has(tableItemsKey)) { + const newTableItem: TableItem = { sectionName: groupName, key: key }; + for (const rn of RUNS) { + newTableItem[rn] = ""; + } + keyValueTableItems.set(tableItemsKey, newTableItem); + } + keyValueTableItems.get(tableItemsKey)![runName] = value; + } + } + } + + const tableColumnDefinitions: TableProps.ColumnDefinition[] = []; + if (!isDummySection) { + tableColumnDefinitions.push({ + id: "section_name", + header: "Section", + cell: (item) => item.sectionName, + isRowHeader: true, + sortingField: "sectionName", + }); + } + tableColumnDefinitions.push({ + id: "key", + header: "Key", + cell: (item) => {item.key}, + isRowHeader: isDummySection, + sortingField: "key", + }); + for (const runName of RUNS) { + tableColumnDefinitions.push({ + id: `${runName}-value`, + header: , + cell: (item) => item[runName], + }); + } + + return { tableItems: Array.from(keyValueTableItems.values()), tableColumnDefinitions }; +} diff --git a/src/report_frontend/src/components/pages/GraphDataPage.tsx b/src/report_frontend/src/components/pages/GraphDataPage.tsx deleted file mode 100644 index f1bd6e38..00000000 --- a/src/report_frontend/src/components/pages/GraphDataPage.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from "react"; -import { DataPageProps, DataType, GraphData } from "../../definitions/types"; -import { PROCESSED_DATA, RUNS } from "../../definitions/data-config"; -import { - Box, - Cards, - CardsProps, - Pagination, - SegmentedControl, - SegmentedControlProps, - TextFilter, -} from "@cloudscape-design/components"; -import { useCollection } from "@cloudscape-design/collection-hooks"; -import Header from "@cloudscape-design/components/header"; -import IframeGraph from "../data/IframeGraph"; -import { DATA_DESCRIPTIONS } from "../../definitions/data-descriptions"; -import { RunHeader } from "../data/RunSystemInfo"; -import { ReportHelpPanelLink } from "../misc/ReportHelpPanel"; - -const NUM_GRAPHS_PER_PAGE = 10; - -/** - * Collect all graph groups across all runs and transform them into the format required - * by SegmentedControl - */ -function getAllGraphGroups(dataType: DataType): SegmentedControlProps.Option[] { - const graphGroupNames: string[] = []; - for (const runName of RUNS) { - const reportData = PROCESSED_DATA[dataType].runs[runName] as GraphData; - if (reportData == undefined) continue; - for (const graphGroup of reportData.graph_groups) { - if (!graphGroupNames.includes(graphGroup.group_name)) { - graphGroupNames.push(graphGroup.group_name); - } - } - } - - const allGraphGroups: SegmentedControlProps.Option[] = []; - for (const groupName of graphGroupNames) { - allGraphGroups.push({ - id: groupName, - text: DATA_DESCRIPTIONS[dataType].fieldDescriptions[groupName]?.readableName || groupName, - }); - } - - return allGraphGroups; -} - -/** - * Compute the list of graph names sorted by size - */ -function getGraphNames(dataType: DataType, graphGroupName: string): string[] { - const graphSizes = new Map(); - for (const runName of RUNS) { - const reportData = PROCESSED_DATA[dataType].runs[runName] as GraphData; - const graphGroup = reportData?.graph_groups.find((graphGroup) => graphGroup.group_name === graphGroupName); - if (graphGroup == undefined) continue; - for (const graphName in graphGroup.graphs) { - if (!graphSizes.has(graphName)) { - graphSizes.set(graphName, 0); - } - graphSizes.set(graphName, graphSizes.get(graphName) + (graphGroup.graphs[graphName]?.graph_size || 0)); - } - } - return Array.from(graphSizes.keys()).sort((a, b) => graphSizes.get(b) - graphSizes.get(a)); -} - -/** - * This component renders the page for graph data type, where the graphs are rendered within Iframes - */ -export default function (props: DataPageProps) { - const allGraphGroups = React.useMemo(() => getAllGraphGroups(props.dataType), [props.dataType]); - - const [graphGroupName, setGraphGroupName] = React.useState(allGraphGroups[0]?.id || ""); - - const graphRowPercentage = Math.floor(100 / RUNS.length); - const cardDefinition: CardsProps.CardDefinition = { - header: (graphName: string) =>
{graphName}
, - sections: RUNS.map((runName) => ({ - id: runName, - header: , - content: (graphName) => ( -
- -
- ), - width: graphRowPercentage, - })), - }; - - const sortedGraphNames = getGraphNames(props.dataType, graphGroupName); - const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(sortedGraphNames, { - filtering: { - filteringFunction: (item: string, filteringText: string) => - item.toLowerCase().includes(filteringText.toLowerCase()), - empty: No graphs were collected, - noMatch: No graphs found, - }, - pagination: { pageSize: Math.floor(NUM_GRAPHS_PER_PAGE / RUNS.length) }, - }); - - return ( - } - stickyHeader={true} - header={ -
} - actions={ - allGraphGroups.length > 1 && ( - setGraphGroupName(detail.selectedId)} - options={allGraphGroups} - /> - ) - } - > - {DATA_DESCRIPTIONS[props.dataType].readableName} -
- } - filter={ - - } - variant={"full-page"} - items={items} - cardDefinition={cardDefinition} - /> - ); -} diff --git a/src/report_frontend/src/components/pages/KeyValueDataPage.tsx b/src/report_frontend/src/components/pages/KeyValueDataPage.tsx index 091e3047..094810ab 100644 --- a/src/report_frontend/src/components/pages/KeyValueDataPage.tsx +++ b/src/report_frontend/src/components/pages/KeyValueDataPage.tsx @@ -1,83 +1,25 @@ -import { DataPageProps, DataType, KeyValueData } from "../../definitions/types"; +import { DataPageProps, KeyValueData } from "../../definitions/types"; import React from "react"; import { PROCESSED_DATA, RUNS } from "../../definitions/data-config"; -import { Box, Pagination, SpaceBetween, Table, TableProps, TextFilter, Toggle } from "@cloudscape-design/components"; +import { Box, Pagination, SpaceBetween, Table, TextFilter, Toggle } from "@cloudscape-design/components"; import { useCollection } from "@cloudscape-design/collection-hooks"; import Header from "@cloudscape-design/components/header"; import { DATA_DESCRIPTIONS } from "../../definitions/data-descriptions"; -import { RunHeader } from "../data/RunSystemInfo"; import { ReportHelpPanelLink } from "../misc/ReportHelpPanel"; import { ShowFindingsPanelButton } from "../analytics/FindingsSplitPanel"; import { useReportState } from "../ReportStateProvider"; +import { buildKeyValueTable, TableItem } from "../data/KeyValueTable"; const NUM_KEY_VALUE_PAIRS_PER_PAGE = 50; -type TableItem = { [key in string]: string }; - /** * Transform processed key value data into formats required by the Table component */ -function getTableItemsAndDefinitions(dataType: DataType) { - // If there is only one KeyValueGroup, and it is an empty string, we consider it as dummy - // and the table will not show the "section" - let isDummySection = true; - // The map from lower-case key-value group name + key to TableItem - we want to make - // group names and keys with only case differences to be still placed at the same row - const keyValueTableItems = new Map(); - - for (const runName of RUNS) { - const reportData = PROCESSED_DATA[dataType].runs[runName] as KeyValueData; - if (reportData === undefined) continue; - for (const groupName in reportData.key_value_groups) { - if (groupName != "") isDummySection = false; - for (const [key, value] of Object.entries(reportData.key_value_groups[groupName].key_values)) { - const tableItemsKey = `${groupName} ${key}`.toLowerCase(); - // If a group name or key is only case-different across runs, they will be placed - // at the same row in the table which shows the first occurrence of the group name - // or key - if (!keyValueTableItems.has(tableItemsKey)) { - const newTableItem = { - sectionName: groupName, - key: key, - }; - for (const runName of RUNS) { - newTableItem[runName] = ""; - } - keyValueTableItems.set(tableItemsKey, newTableItem); - } - const tableItem = keyValueTableItems.get(tableItemsKey); - tableItem[runName] = value; - } - } - } - - // ColumnDefinition defines how the table items will be shown - const tableColumnDefinitions: TableProps.ColumnDefinition[] = []; - if (!isDummySection) { - tableColumnDefinitions.push({ - id: "section_name", - header: "Section", - cell: (item) => item.sectionName, - isRowHeader: true, - sortingField: "sectionName", - }); - } - tableColumnDefinitions.push({ - id: "key", - header: "Key", - cell: (item) => {item.key}, - isRowHeader: isDummySection, - sortingField: "key", - }); - for (const runName of RUNS) { - tableColumnDefinitions.push({ - id: `${runName}-value`, - header: , - cell: (item) => item[runName], - }); - } - - return { tableItems: Array.from(keyValueTableItems.values()), tableColumnDefinitions }; +function getTableItemsAndDefinitions(dataType: DataPageProps["dataType"]) { + const dataByRun = new Map( + RUNS.map((runName) => [runName, PROCESSED_DATA[dataType].runs[runName] as KeyValueData | undefined]), + ); + return buildKeyValueTable(dataByRun); } /** diff --git a/src/report_frontend/src/components/pages/ProfilingDataPage.tsx b/src/report_frontend/src/components/pages/ProfilingDataPage.tsx new file mode 100644 index 00000000..96ed70a1 --- /dev/null +++ b/src/report_frontend/src/components/pages/ProfilingDataPage.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { DataPageProps, DataType, ProfilingData } from "../../definitions/types"; +import { PROCESSED_DATA, RUNS } from "../../definitions/data-config"; +import { + Box, + Cards, + CardsProps, + Pagination, + SegmentedControl, + SegmentedControlProps, + TextFilter, +} from "@cloudscape-design/components"; +import { useCollection } from "@cloudscape-design/collection-hooks"; +import Header from "@cloudscape-design/components/header"; +import IframeGraph from "../data/IframeGraph"; +import { DATA_DESCRIPTIONS } from "../../definitions/data-descriptions"; +import { RunHeader } from "../data/RunSystemInfo"; +import { ReportHelpPanelLink } from "../misc/ReportHelpPanel"; + +const NUM_PROFILERS_PER_PAGE = 10; + +/** + * Collect all profile names across all runs and profilers and transform them into the format + * required by SegmentedControl + */ +function getAllProfileNames(dataType: DataType): SegmentedControlProps.Option[] { + const profileNames: string[] = []; + for (const runName of RUNS) { + const reportData = PROCESSED_DATA[dataType].runs[runName] as ProfilingData; + if (reportData == undefined) continue; + for (const profiler of Object.values(reportData.profilers)) { + for (const profileName in profiler.profiles) { + if (!profileNames.includes(profileName)) { + profileNames.push(profileName); + } + } + } + } + return profileNames + .sort((a, b) => b.localeCompare(a)) + .map((profileName) => ({ + id: profileName, + text: DATA_DESCRIPTIONS[dataType].fieldDescriptions[profileName]?.readableName || profileName, + })); +} + +/** + * Compute the list of profiler instance names that have the given profile, sorted by size + */ +function getProfilerNames(dataType: DataType, profileName: string): string[] { + const profilerSizes = new Map(); + for (const runName of RUNS) { + const reportData = PROCESSED_DATA[dataType].runs[runName] as ProfilingData; + if (reportData == undefined) continue; + for (const [instanceName, profiler] of Object.entries(reportData.profilers)) { + const profile = profiler.profiles[profileName]; + if (profile == undefined) continue; + if (!profilerSizes.has(instanceName)) profilerSizes.set(instanceName, 0); + profilerSizes.set(instanceName, profilerSizes.get(instanceName) + (profile.profile_graph?.graph_size || 0)); + } + } + return Array.from(profilerSizes.keys()).sort((a, b) => profilerSizes.get(b) - profilerSizes.get(a)); +} + +/** + * This component renders the page for ProfilingData data type, where the graphs are rendered within Iframes + */ +export default function (props: DataPageProps) { + const allProfileNames = React.useMemo(() => getAllProfileNames(props.dataType), [props.dataType]); + + const [profileName, setProfileName] = React.useState(allProfileNames[0]?.id || ""); + + const graphRowPercentage = Math.floor(100 / RUNS.length); + const cardDefinition: CardsProps.CardDefinition = { + header: (instanceName: string) =>
{instanceName}
, + sections: RUNS.map((runName) => ({ + id: runName, + header: , + content: (instanceName) => ( +
+ +
+ ), + width: graphRowPercentage, + })), + }; + + const sortedProfilerNames = getProfilerNames(props.dataType, profileName); + const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection( + sortedProfilerNames, + { + filtering: { + filteringFunction: (item: string, filteringText: string) => + item.toLowerCase().includes(filteringText.toLowerCase()), + empty: No profilers were collected, + noMatch: No profilers found, + }, + pagination: { pageSize: Math.floor(NUM_PROFILERS_PER_PAGE / RUNS.length) }, + }, + ); + + return ( + } + stickyHeader={true} + header={ +
} + actions={ + allProfileNames.length > 1 && ( + setProfileName(detail.selectedId)} + options={allProfileNames} + /> + ) + } + > + {DATA_DESCRIPTIONS[props.dataType].readableName} +
+ } + filter={ + + } + variant={"full-page"} + items={items} + cardDefinition={cardDefinition} + /> + ); +} diff --git a/src/report_frontend/src/definitions/types.ts b/src/report_frontend/src/definitions/types.ts index eef3e6bf..c31625ff 100644 --- a/src/report_frontend/src/definitions/types.ts +++ b/src/report_frontend/src/definitions/types.ts @@ -23,10 +23,10 @@ export const ALL_DATA_TYPES = [ ] as const; export type DataType = (typeof ALL_DATA_TYPES)[number]; -export type DataFormat = "time_series" | "key_value" | "text" | "graph" | "unknown"; +export type DataFormat = "time_series" | "key_value" | "text" | "profile" | "unknown"; // See src/data/data_formats.rs -export type AperfData = TimeSeriesData | KeyValueData | TextData | GraphData; +export type AperfData = TimeSeriesData | KeyValueData | TextData | ProfilingData; export interface ReportData { readonly data_name: DataType; @@ -65,13 +65,19 @@ export interface TextData { readonly lines: string[]; } -export interface GraphData { - readonly graph_groups: GraphGroup[]; +export interface ProfilingData { + readonly profilers: { [key in string]: Profiler }; } -export interface GraphGroup { - readonly group_name: string; - readonly graphs: { [key in string]: GraphInfo }; +export interface Profiler { + readonly start_time_ms: number; + readonly block_width_ms: number; + readonly metadata: KeyValueData; + readonly profiles: { [key in string]: Profile }; +} + +export interface Profile { + readonly profile_graph: GraphInfo; } export interface GraphInfo { diff --git a/tests/analytics/test_profile_metadata_comparison_rule.rs b/tests/analytics/test_profile_metadata_comparison_rule.rs index ef7df8a9..488fe4be 100644 --- a/tests/analytics/test_profile_metadata_comparison_rule.rs +++ b/tests/analytics/test_profile_metadata_comparison_rule.rs @@ -1,7 +1,7 @@ use aperf::analytics::profile_metadata_comparison_rule::ProfileMetadataComparisonRule; use aperf::analytics::{Analyze, DataFindings, Score, BASE_RUN_NAME}; use aperf::data::common::data_formats::{ - AperfData, GraphData, KeyValueData, KeyValueGroup, ProfilerData, + AperfData, KeyValueData, KeyValueGroup, Profiler, ProfilingData, }; use aperf::data::common::processed_data_accessor::ProcessedDataAccessor; use std::collections::HashMap; @@ -24,36 +24,33 @@ fn create_key_value_data(group: &str, key: &str, value: Option<&str>) -> KeyValu KeyValueData { key_value_groups } } -fn create_graph_data(metadata: Vec) -> GraphData { - let mut map = HashMap::new(); +fn create_profiling_data(metadata: Vec) -> ProfilingData { + let mut profilers = HashMap::new(); for (i, data) in metadata.into_iter().enumerate() { - map.insert( + profilers.insert( format!("profile_{}", i), - ProfilerData { - start_time_ms: 0, - block_width_ms: 0, + Profiler { metadata: data, - profiles: HashMap::new(), + ..Default::default() }, ); } - GraphData { - graph_groups: vec![], - profiler_data_map: map, - } + ProfilingData { profilers } } #[test] fn test_values_match_across_runs() { set_base_run("run1"); - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data1 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data2 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), ], ); @@ -80,13 +77,15 @@ fn test_values_match_across_runs() { fn test_values_differ_across_runs() { set_base_run("run1"); - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); + let profiling_data1 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data2 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), ], ); @@ -114,13 +113,14 @@ fn test_values_differ_across_runs() { fn test_field_missing_in_non_base_run() { set_base_run("run1"); - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", None)]); + let profiling_data1 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data2 = create_profiling_data(vec![create_key_value_data("cpu", "mode", None)]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), ], ); @@ -148,13 +148,14 @@ fn test_field_missing_in_non_base_run() { fn test_field_missing_in_base_run() { set_base_run("run1"); - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", None)]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data1 = create_profiling_data(vec![create_key_value_data("cpu", "mode", None)]); + let profiling_data2 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), ], ); @@ -181,15 +182,18 @@ fn test_field_missing_in_base_run() { fn test_multiple_non_base_runs() { set_base_run("run1"); - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data3 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); + let profiling_data1 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data2 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data3 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), - ("run3", AperfData::Graph(graph_data3)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), + ("run3", AperfData::Profile(profiling_data3)), ], ); diff --git a/tests/analytics/test_profile_metadata_expected_rule.rs b/tests/analytics/test_profile_metadata_expected_rule.rs index f72fc8c4..80987d67 100644 --- a/tests/analytics/test_profile_metadata_expected_rule.rs +++ b/tests/analytics/test_profile_metadata_expected_rule.rs @@ -1,7 +1,7 @@ use aperf::analytics::profile_metadata_expected_rule::ProfileMetadataExpectedRule; use aperf::analytics::{Analyze, DataFindings, Score}; use aperf::data::common::data_formats::{ - AperfData, GraphData, KeyValueData, KeyValueGroup, ProfilerData, + AperfData, KeyValueData, KeyValueGroup, Profiler, ProfilingData, }; use aperf::data::common::processed_data_accessor::ProcessedDataAccessor; use std::collections::HashMap; @@ -20,30 +20,28 @@ fn create_key_value_data(group: &str, key: &str, value: Option<&str>) -> KeyValu KeyValueData { key_value_groups } } -fn create_graph_data(metadata: Vec) -> GraphData { - let mut map = HashMap::new(); +fn create_profiling_data(metadata: Vec) -> ProfilingData { + let mut profilers = HashMap::new(); for (i, data) in metadata.into_iter().enumerate() { - map.insert( + profilers.insert( format!("profile_{}", i), - ProfilerData { - start_time_ms: 0, - block_width_ms: 0, + Profiler { metadata: data, - profiles: HashMap::new(), + ..Default::default() }, ); } - GraphData { - graph_groups: vec![], - profiler_data_map: map, - } + ProfilingData { profilers } } #[test] fn test_field_matches_expected_value() { - let graph_data = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileMetadataExpectedRule { rule_name: "test_rule", @@ -67,9 +65,12 @@ fn test_field_matches_expected_value() { #[test] fn test_field_does_not_match_expected_value() { - let graph_data = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileMetadataExpectedRule { rule_name: "test_rule", @@ -94,9 +95,11 @@ fn test_field_does_not_match_expected_value() { #[test] fn test_field_missing_should_exist() { - let graph_data = create_graph_data(vec![create_key_value_data("cpu", "mode", None)]); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = create_profiling_data(vec![create_key_value_data("cpu", "mode", None)]); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileMetadataExpectedRule { rule_name: "test_rule", @@ -121,13 +124,15 @@ fn test_field_missing_should_exist() { #[test] fn test_regex_pattern_match() { - let graph_data = create_graph_data(vec![create_key_value_data( + let profiling_data = create_profiling_data(vec![create_key_value_data( "cpu", "mode", Some("kernel_mode"), )]); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileMetadataExpectedRule { rule_name: "test_rule", @@ -151,13 +156,15 @@ fn test_regex_pattern_match() { #[test] fn test_multiple_runs() { - let graph_data1 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); - let graph_data2 = create_graph_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); + let profiling_data1 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("kernel"))]); + let profiling_data2 = + create_profiling_data(vec![create_key_value_data("cpu", "mode", Some("user"))]); let processed_data = create_processed_data( "test_data", vec![ - ("run1", AperfData::Graph(graph_data1)), - ("run2", AperfData::Graph(graph_data2)), + ("run1", AperfData::Profile(profiling_data1)), + ("run2", AperfData::Profile(profiling_data2)), ], ); diff --git a/tests/analytics/test_profile_stack_frame_threshold_rule.rs b/tests/analytics/test_profile_stack_frame_threshold_rule.rs index 0029f9e3..5ce4619e 100644 --- a/tests/analytics/test_profile_stack_frame_threshold_rule.rs +++ b/tests/analytics/test_profile_stack_frame_threshold_rule.rs @@ -1,6 +1,6 @@ use aperf::analytics::profile_stack_frame_threshold_rule::ProfileStackFrameThresholdRule; use aperf::analytics::{Analyze, DataFindings, Score}; -use aperf::data::common::data_formats::{AperfData, GraphData, ProfilerData}; +use aperf::data::common::data_formats::{AperfData, Profiler, ProfilingData}; use aperf::data::common::processed_data_accessor::ProcessedDataAccessor; use aperf::profiling::ThreadState; use std::collections::HashMap; @@ -12,8 +12,8 @@ use super::test_helpers::{create_processed_data, DataFindingsExt}; /// frame1;frame2;frame3 110 /// frame4;frame5;frame6 75 /// frame1;frame7 90 -fn create_profiler_data(group_name: &str) -> ProfilerData { - let mut pd = ProfilerData::new(0, 100); +fn create_profiler_instance(group_name: &str) -> Profiler { + let mut pd = Profiler::new(0, 100); let ts = ThreadState::from_str("STATE_DEFAULT"); pd.insert_stack( group_name, @@ -46,24 +46,26 @@ fn create_profiler_data(group_name: &str) -> ProfilerData { pd } -fn create_graph_data(group_name: &str) -> GraphData { - let mut profiler_data_map = HashMap::new(); - profiler_data_map.insert("profile_0".to_string(), create_profiler_data(group_name)); - GraphData { - graph_groups: vec![], - profiler_data_map, - } +fn create_profiler_data(group_name: &str) -> ProfilingData { + let mut profilers = HashMap::new(); + profilers.insert( + "profile_0".to_string(), + create_profiler_instance(group_name), + ); + ProfilingData { profilers } } #[test] fn test_below_threshold() { - let graph_data = create_graph_data("cpu"); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = create_profiler_data("cpu"); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileStackFrameThresholdRule { rule_name: "test_rule", - graph_group: "cpu", + profile_type: "cpu", stack_frame: &[&["frame5"]], frame_type: None, thread_states: &[], @@ -85,13 +87,15 @@ fn test_below_threshold() { #[test] fn test_above_threshold() { - let graph_data = create_graph_data("cpu"); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = create_profiler_data("cpu"); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileStackFrameThresholdRule { rule_name: "test_rule", - graph_group: "cpu", + profile_type: "cpu", stack_frame: &[&["frame1"]], frame_type: None, thread_states: &[], @@ -114,13 +118,15 @@ fn test_above_threshold() { #[test] fn test_stack_pattern() { - let graph_data = create_graph_data("cpu"); - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let profiling_data = create_profiler_data("cpu"); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileStackFrameThresholdRule { rule_name: "test_rule", - graph_group: "cpu", + profile_type: "cpu", stack_frame: &[&["frame1", "frame2", "frame3"]], frame_type: None, thread_states: &[], @@ -143,16 +149,17 @@ fn test_stack_pattern() { #[test] fn test_missing_metric() { - let graph_data = GraphData { - graph_groups: vec![], - profiler_data_map: HashMap::new(), + let profiling_data = ProfilingData { + profilers: HashMap::new(), }; - let processed_data = - create_processed_data("test_data", vec![("run1", AperfData::Graph(graph_data))]); + let processed_data = create_processed_data( + "test_data", + vec![("run1", AperfData::Profile(profiling_data))], + ); let rule = ProfileStackFrameThresholdRule { rule_name: "test_rule", - graph_group: "alloc", + profile_type: "alloc", stack_frame: &[&["frame1"]], frame_type: None, thread_states: &[], diff --git a/tests/test_java_profile.rs b/tests/test_java_profile.rs index bf2073bd..46318332 100644 --- a/tests/test_java_profile.rs +++ b/tests/test_java_profile.rs @@ -67,15 +67,12 @@ fn test_process_raw_data_with_valid_files() { let result = java_profile.process_raw_data(params, vec![]); assert!(result.is_ok()); - if let Ok(AperfData::Graph(graph_data)) = result { - assert_eq!(graph_data.graph_groups.len(), 4); - for metric in &["cpu", "alloc", "wall", "legacy"] { - let group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == *metric) - .unwrap(); - assert_eq!(group.graphs.len(), 2); + if let Ok(AperfData::Profile(profiling_data)) = result { + // 2 JVMs + assert_eq!(profiling_data.profilers.len(), 2); + // Each JVM has 4 metrics + for (_name, profiler) in &profiling_data.profilers { + assert_eq!(profiler.profiles.len(), 4); } } } @@ -88,10 +85,8 @@ fn test_process_raw_data_with_missing_jps_map() { let result = java_profile.process_raw_data(params, vec![]); assert!(result.is_ok()); - if let Ok(AperfData::Graph(graph_data)) = result { - for group in &graph_data.graph_groups { - assert!(group.graphs.is_empty()); - } + if let Ok(AperfData::Profile(profiling_data)) = result { + assert!(profiling_data.profilers.is_empty()); } } @@ -114,20 +109,13 @@ fn test_process_raw_data_with_duplicate_jvm_names() { let result = java_profile.process_raw_data(params, vec![]); assert!(result.is_ok()); - if let Ok(AperfData::Graph(graph_data)) = result { - let cpu_group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == "cpu") - .unwrap(); - assert_eq!(cpu_group.graphs.len(), 3); - - let graph_names: Vec = cpu_group.graphs.keys().cloned().collect(); - assert!(graph_names.iter().any(|name| name.contains("TestApp") - && !name.contains("(1)") - && !name.contains("(2)"))); - assert!(graph_names.iter().any(|name| name.contains("TestApp (1)"))); - assert!(graph_names.iter().any(|name| name.contains("TestApp (2)"))); + if let Ok(AperfData::Profile(profiling_data)) = result { + // 3 deduped JVM entries + assert_eq!(profiling_data.profilers.len(), 3); + let names: Vec = profiling_data.profilers.keys().cloned().collect(); + assert!(names.iter().any(|n| n == "TestApp")); + assert!(names.iter().any(|n| n == "TestApp (1)")); + assert!(names.iter().any(|n| n == "TestApp (2)")); } } @@ -144,10 +132,8 @@ fn test_process_raw_data_with_no_html_files() { let result = java_profile.process_raw_data(params, vec![]); assert!(result.is_ok()); - if let Ok(AperfData::Graph(graph_data)) = result { - for group in &graph_data.graph_groups { - assert!(group.graphs.is_empty()); - } + if let Ok(AperfData::Profile(profiling_data)) = result { + assert!(profiling_data.profilers.is_empty()); } } @@ -191,75 +177,47 @@ fn test_process_raw_data_with_complex_duplicate_names_and_missing_files() { let result = java_profile.process_raw_data(params, vec![]); assert!(result.is_ok()); - if let Ok(AperfData::Graph(graph_data)) = result { - let cpu_group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == "cpu") - .unwrap(); - assert_eq!(cpu_group.graphs.len(), 7); - let cpu_names: Vec = cpu_group.graphs.keys().cloned().collect(); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App (1)")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App (2)")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App (3)")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App (4)")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: App (5)")); - assert!(cpu_names.iter().any(|name| name == "(cpu) JVM: Service")); - - let alloc_group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == "alloc") - .unwrap(); - assert_eq!(alloc_group.graphs.len(), 5); - let alloc_names: Vec = alloc_group.graphs.keys().cloned().collect(); - assert_eq!( - alloc_names - .iter() - .filter(|name| name.starts_with("(alloc) JVM: App")) - .count(), - 3 - ); - assert_eq!( - alloc_names - .iter() - .filter(|name| name.starts_with("(alloc) JVM: Service")) - .count(), - 2 - ); - - let wall_group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == "wall") - .unwrap(); - assert_eq!(wall_group.graphs.len(), 4); - let wall_names: Vec = wall_group.graphs.keys().cloned().collect(); - assert_eq!( - wall_names - .iter() - .filter(|name| name.starts_with("(wall) JVM: App")) - .count(), - 3 - ); - assert_eq!( - wall_names - .iter() - .filter(|name| name.starts_with("(wall) JVM: Service")) - .count(), - 1 - ); - - let legacy_group = graph_data - .graph_groups - .iter() - .find(|g| g.group_name == "legacy") - .unwrap(); - assert_eq!(legacy_group.graphs.len(), 2); - let legacy_names: Vec = legacy_group.graphs.keys().cloned().collect(); - assert!(legacy_names - .iter() - .any(|name| name == "(legacy) JVM: Service")); + if let Ok(AperfData::Profile(profiling_data)) = result { + // 8 JVMs total (6 App deduped + 2 Service deduped) + assert_eq!(profiling_data.profilers.len(), 8); + + // Count total profiles per metric across all JVMs + let cpu_count: usize = profiling_data + .profilers + .values() + .filter(|pd| pd.profiles.contains_key("cpu")) + .count(); + assert_eq!(cpu_count, 7); // 6 App + 1 Service + + let alloc_count: usize = profiling_data + .profilers + .values() + .filter(|pd| pd.profiles.contains_key("alloc")) + .count(); + assert_eq!(alloc_count, 5); // 3 App + 2 Service + + let wall_count: usize = profiling_data + .profilers + .values() + .filter(|pd| pd.profiles.contains_key("wall")) + .count(); + assert_eq!(wall_count, 4); // 3 App + 1 Service + + let legacy_count: usize = profiling_data + .profilers + .values() + .filter(|pd| pd.profiles.contains_key("legacy")) + .count(); + assert_eq!(legacy_count, 2); // 2 Service + + // Verify deduped App names exist + let names: Vec = profiling_data.profilers.keys().cloned().collect(); + assert!(names.iter().any(|n| n == "App")); + assert!(names.iter().any(|n| n == "App (1)")); + assert!(names.iter().any(|n| n == "App (2)")); + assert!(names.iter().any(|n| n == "App (3)")); + assert!(names.iter().any(|n| n == "App (4)")); + assert!(names.iter().any(|n| n == "App (5)")); + assert!(names.iter().any(|n| n == "Service")); } }