Merge "add do_sys_open ftrace events" into main
diff --git a/BUILD b/BUILD
index 91bd49b..3cf2eee 100644
--- a/BUILD
+++ b/BUILD
@@ -502,6 +502,72 @@
     linkstatic = True,
 )
 
+# GN target: //src/traceconv:libpprofbuilder
+perfetto_cc_library(
+    name = "libpprofbuilder",
+    srcs = [
+        ":src_profiling_deobfuscator",
+        ":src_profiling_symbolizer_symbolize_database",
+        ":src_profiling_symbolizer_symbolizer",
+        ":src_trace_processor_util_build_id",
+        ":src_traceconv_pprofbuilder",
+        ":src_traceconv_utils",
+    ],
+    hdrs = [
+        ":include_perfetto_base_base",
+        ":include_perfetto_ext_base_base",
+        ":include_perfetto_profiling_pprof_builder",
+        ":include_perfetto_protozero_protozero",
+        ":include_perfetto_public_abi_base",
+        ":include_perfetto_public_base",
+        ":include_perfetto_public_protozero",
+        ":include_perfetto_trace_processor_basic_types",
+        ":include_perfetto_trace_processor_storage",
+        ":include_perfetto_trace_processor_trace_processor",
+    ],
+    visibility = PERFETTO_CONFIG.public_visibility,
+    deps = [
+        ":protos_perfetto_common_zero",
+        ":protos_perfetto_config_android_zero",
+        ":protos_perfetto_config_ftrace_zero",
+        ":protos_perfetto_config_gpu_zero",
+        ":protos_perfetto_config_inode_file_zero",
+        ":protos_perfetto_config_interceptors_zero",
+        ":protos_perfetto_config_power_zero",
+        ":protos_perfetto_config_process_stats_zero",
+        ":protos_perfetto_config_profiling_zero",
+        ":protos_perfetto_config_statsd_zero",
+        ":protos_perfetto_config_sys_stats_zero",
+        ":protos_perfetto_config_system_info_zero",
+        ":protos_perfetto_config_track_event_zero",
+        ":protos_perfetto_config_zero",
+        ":protos_perfetto_trace_android_winscope_common_zero",
+        ":protos_perfetto_trace_android_winscope_regular_zero",
+        ":protos_perfetto_trace_android_zero",
+        ":protos_perfetto_trace_chrome_zero",
+        ":protos_perfetto_trace_etw_zero",
+        ":protos_perfetto_trace_filesystem_zero",
+        ":protos_perfetto_trace_ftrace_zero",
+        ":protos_perfetto_trace_gpu_zero",
+        ":protos_perfetto_trace_interned_data_zero",
+        ":protos_perfetto_trace_minimal_zero",
+        ":protos_perfetto_trace_non_minimal_zero",
+        ":protos_perfetto_trace_perfetto_zero",
+        ":protos_perfetto_trace_power_zero",
+        ":protos_perfetto_trace_profiling_zero",
+        ":protos_perfetto_trace_ps_zero",
+        ":protos_perfetto_trace_statsd_zero",
+        ":protos_perfetto_trace_sys_stats_zero",
+        ":protos_perfetto_trace_system_info_zero",
+        ":protos_perfetto_trace_track_event_zero",
+        ":protos_perfetto_trace_translation_zero",
+        ":protos_third_party_pprof_zero",
+        ":protozero",
+        ":src_trace_processor_containers_containers",
+    ] + PERFETTO_CONFIG.deps.zlib,
+    linkstatic = True,
+)
+
 # GN target: //test:client_api_example
 perfetto_cc_binary(
     name = "client_api_example",
@@ -6865,74 +6931,6 @@
            PERFETTO_CONFIG.deps.demangle_wrapper,
 )
 
-# GN target: //src/traceconv:libpprofbuilder
-perfetto_cc_library(
-    name = "libpprofbuilder",
-    srcs = [
-        ":src_profiling_deobfuscator",
-        ":src_profiling_symbolizer_symbolize_database",
-        ":src_profiling_symbolizer_symbolizer",
-        ":src_trace_processor_util_build_id",
-        ":src_traceconv_pprofbuilder",
-        ":src_traceconv_utils",
-    ],
-    hdrs = [
-        ":include_perfetto_base_base",
-        ":include_perfetto_ext_base_base",
-        ":include_perfetto_profiling_pprof_builder",
-        ":include_perfetto_protozero_protozero",
-        ":include_perfetto_public_abi_base",
-        ":include_perfetto_public_base",
-        ":include_perfetto_public_protozero",
-        ":include_perfetto_trace_processor_basic_types",
-        ":include_perfetto_trace_processor_storage",
-        ":include_perfetto_trace_processor_trace_processor",
-    ],
-    visibility = [
-        "//visibility:public",
-    ],
-    deps = [
-        ":protos_perfetto_common_zero",
-        ":protos_perfetto_config_android_zero",
-        ":protos_perfetto_config_ftrace_zero",
-        ":protos_perfetto_config_gpu_zero",
-        ":protos_perfetto_config_inode_file_zero",
-        ":protos_perfetto_config_interceptors_zero",
-        ":protos_perfetto_config_power_zero",
-        ":protos_perfetto_config_process_stats_zero",
-        ":protos_perfetto_config_profiling_zero",
-        ":protos_perfetto_config_statsd_zero",
-        ":protos_perfetto_config_sys_stats_zero",
-        ":protos_perfetto_config_system_info_zero",
-        ":protos_perfetto_config_track_event_zero",
-        ":protos_perfetto_config_zero",
-        ":protos_perfetto_trace_android_winscope_common_zero",
-        ":protos_perfetto_trace_android_winscope_regular_zero",
-        ":protos_perfetto_trace_android_zero",
-        ":protos_perfetto_trace_chrome_zero",
-        ":protos_perfetto_trace_etw_zero",
-        ":protos_perfetto_trace_filesystem_zero",
-        ":protos_perfetto_trace_ftrace_zero",
-        ":protos_perfetto_trace_gpu_zero",
-        ":protos_perfetto_trace_interned_data_zero",
-        ":protos_perfetto_trace_minimal_zero",
-        ":protos_perfetto_trace_non_minimal_zero",
-        ":protos_perfetto_trace_perfetto_zero",
-        ":protos_perfetto_trace_power_zero",
-        ":protos_perfetto_trace_profiling_zero",
-        ":protos_perfetto_trace_ps_zero",
-        ":protos_perfetto_trace_statsd_zero",
-        ":protos_perfetto_trace_sys_stats_zero",
-        ":protos_perfetto_trace_system_info_zero",
-        ":protos_perfetto_trace_track_event_zero",
-        ":protos_perfetto_trace_translation_zero",
-        ":protos_third_party_pprof_zero",
-        ":protozero",
-        ":src_trace_processor_containers_containers",
-    ] + PERFETTO_CONFIG.deps.zlib,
-    linkstatic = True,
-)
-
 # GN target: //src/traceconv:traceconv
 perfetto_cc_binary(
     name = "traceconv",
diff --git a/protos/perfetto/metrics/android/startup_metric.proto b/protos/perfetto/metrics/android/startup_metric.proto
index 1de0b47..86c206c 100644
--- a/protos/perfetto/metrics/android/startup_metric.proto
+++ b/protos/perfetto/metrics/android/startup_metric.proto
@@ -303,8 +303,8 @@
     // sorted by the duration in descending order.
     // By checking out the top slices/threads, developers can identify specific
     // slices or threads for further investigation.
-    repeated TraceSliceSection trace_slice_sections = 7;
-    repeated TraceThreadSection trace_thread_sections = 8;
+    optional TraceSliceSectionInfo trace_slice_sections = 7;
+    optional TraceThreadSectionInfo trace_thread_sections = 8;
 
     // Details specific for a reason.
     optional string additional_info = 9;
@@ -355,6 +355,13 @@
     optional uint32 thread_tid = 6;
   }
 
+  // Information for the SliceSections
+  message TraceSliceSectionInfo {
+    repeated TraceSliceSection slice_section = 1;
+    optional int64 start_timestamp = 2;
+    optional int64 end_timestamp = 3;
+  }
+
   // Contains information for a section of a thread.
   message TraceThreadSection {
     optional int64 start_timestamp = 1;
@@ -371,6 +378,13 @@
     optional uint32 thread_tid = 6;
   }
 
+  // Information for the ThreadSections
+  message TraceThreadSectionInfo {
+    repeated TraceThreadSection thread_section = 1;
+    optional int64 start_timestamp  = 2;
+    optional int64 end_timestamp = 3;
+  }
+
   // Next id: 26
   message Startup {
     // Random id uniquely identifying an app startup in this trace.
diff --git a/protos/perfetto/metrics/perfetto_merged_metrics.proto b/protos/perfetto/metrics/perfetto_merged_metrics.proto
index 275e373..e2fc3a3 100644
--- a/protos/perfetto/metrics/perfetto_merged_metrics.proto
+++ b/protos/perfetto/metrics/perfetto_merged_metrics.proto
@@ -2541,8 +2541,8 @@
     // sorted by the duration in descending order.
     // By checking out the top slices/threads, developers can identify specific
     // slices or threads for further investigation.
-    repeated TraceSliceSection trace_slice_sections = 7;
-    repeated TraceThreadSection trace_thread_sections = 8;
+    optional TraceSliceSectionInfo trace_slice_sections = 7;
+    optional TraceThreadSectionInfo trace_thread_sections = 8;
 
     // Details specific for a reason.
     optional string additional_info = 9;
@@ -2593,6 +2593,13 @@
     optional uint32 thread_tid = 6;
   }
 
+  // Information for the SliceSections
+  message TraceSliceSectionInfo {
+    repeated TraceSliceSection slice_section = 1;
+    optional int64 start_timestamp = 2;
+    optional int64 end_timestamp = 3;
+  }
+
   // Contains information for a section of a thread.
   message TraceThreadSection {
     optional int64 start_timestamp = 1;
@@ -2609,6 +2616,13 @@
     optional uint32 thread_tid = 6;
   }
 
+  // Information for the ThreadSections
+  message TraceThreadSectionInfo {
+    repeated TraceThreadSection thread_section = 1;
+    optional int64 start_timestamp  = 2;
+    optional int64 end_timestamp = 3;
+  }
+
   // Next id: 26
   message Startup {
     // Random id uniquely identifying an app startup in this trace.
diff --git a/protos/perfetto/trace/android/server/windowmanagerservice.proto b/protos/perfetto/trace/android/server/windowmanagerservice.proto
index dcb4583..2c22522 100644
--- a/protos/perfetto/trace/android/server/windowmanagerservice.proto
+++ b/protos/perfetto/trace/android/server/windowmanagerservice.proto
@@ -454,6 +454,7 @@
   repeated RectProto unrestricted_keep_clear_areas = 46;
   repeated InsetsSourceProto mergedLocalInsetsSources = 47;
   optional int32 requested_visible_types = 48;
+  optional RectProto dim_bounds = 49;
 }
 
 message IdentifierProto {
diff --git a/python/perfetto/trace_processor/metrics.descriptor b/python/perfetto/trace_processor/metrics.descriptor
index 728882b..ed15b47 100644
--- a/python/perfetto/trace_processor/metrics.descriptor
+++ b/python/perfetto/trace_processor/metrics.descriptor
Binary files differ
diff --git a/python/tools/check_imports.py b/python/tools/check_imports.py
index 2695450..37c1a95 100755
--- a/python/tools/check_imports.py
+++ b/python/tools/check_imports.py
@@ -115,15 +115,9 @@
     (['/public/lib/colorizer'], '/core/feature_flags'),
 
     # TODO(primiano): Record page-related technical debt.
-    ('/frontend/record*', '/controller/*'),
-    ('/frontend/permalink', '/controller/*'),
-    ('/common/*', '/controller/record_config_types'),
-    ('/controller/index', '/common/recordingV2/target_factories/index'),
-    ('/common/recordingV2/*', '/controller/*'),
-    ('/controller/record_controller*', '*'),
-    ('/controller/adb_*', '*'),
-    ('/chrome_extension/chrome_tracing_controller', '/controller/*'),
-    ('/chrome_extension/chrome_tracing_controller', '/core/trace_config_utils'),
+    ('/plugins/dev.perfetto.RecordTrace/*', '/frontend/globals'),
+    ('/chrome_extension/chrome_tracing_controller',
+     '/plugins/dev.perfetto.RecordTrace/*'),
 
     # TODO(primiano): query-table tech debt.
     (
@@ -150,9 +144,6 @@
     # Bigtrace deps.
     ('/bigtrace/*', ['/base/*', '/widgets/*', '/trace_processor/*']),
 
-    # TODO(primiano): rationalize recordingv2. RecordingV2 is a mess of subdirs.
-    ('/common/recordingV2/*', '/common/recordingV2/*'),
-
     # TODO(primiano): misc tech debt.
     ('/public/lib/extensions', '/frontend/*'),
     ('/bigtrace/index', ['/core/live_reload', '/core/raf_scheduler']),
diff --git a/src/trace_processor/importers/ftrace/ftrace_parser.cc b/src/trace_processor/importers/ftrace/ftrace_parser.cc
index d9b130e..59b2446 100644
--- a/src/trace_processor/importers/ftrace/ftrace_parser.cc
+++ b/src/trace_processor/importers/ftrace/ftrace_parser.cc
@@ -2268,6 +2268,22 @@
   // family) and thread creation (clone(CLONE_THREAD, ...)).
   static const uint32_t kCloneThread = 0x00010000;  // From kernel's sched.h.
 
+  if (PERFETTO_UNLIKELY(new_tid == 0)) {
+    // In the case of boot-time tracing (kernel is started with tracing
+    // enabled), the ftrace buffer will see /bin/init creating swapper/0 tasks:
+    // event {
+    //  pid: 1
+    //  task_newtask {
+    //    pid: 0
+    //    comm: "swapper/0"
+    //  }
+    // }
+    // Skip these task_newtask events since they are kernel idle tasks.
+    PERFETTO_DCHECK(source_tid == 1);
+    PERFETTO_DCHECK(base::StartsWith(evt.comm().ToStdString(), "swapper"));
+    return;
+  }
+
   // If the process is a fork, start a new process.
   if ((clone_flags & kCloneThread) == 0) {
     // This is a plain-old fork() or equivalent.
diff --git a/src/trace_processor/importers/proto/android_probes_parser.cc b/src/trace_processor/importers/proto/android_probes_parser.cc
index 2da16c9..7c3e9f9 100644
--- a/src/trace_processor/importers/proto/android_probes_parser.cc
+++ b/src/trace_processor/importers/proto/android_probes_parser.cc
@@ -147,8 +147,10 @@
         TrackTracker::Group::kPower, batt_power_id);
     auto current = evt.current_ua();
     auto voltage = evt.voltage_uv();
-    context_->event_tracker->PushCounter(
-        ts, static_cast<double>(current * voltage / 1000000000), track);
+    // Current is negative when discharging, but we want the power counter to
+    // always be positive, so take the absolute value.
+    auto power = std::abs(static_cast<double>(current * voltage / 1000000000));
+    context_->event_tracker->PushCounter(ts, power, track);
   }
 }
 
diff --git a/src/trace_processor/metrics/sql/android/startup/slow_start_reasons.sql b/src/trace_processor/metrics/sql/android/startup/slow_start_reasons.sql
index 2d5b646..8a4a7dd 100644
--- a/src/trace_processor/metrics/sql/android/startup/slow_start_reasons.sql
+++ b/src/trace_processor/metrics/sql/android/startup/slow_start_reasons.sql
@@ -46,10 +46,13 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_main_thread_time_for_launch_in_runnable_state(
   startup_id LONG, num_threads INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceThreadSection(
-    'start_timestamp', ts, 'end_timestamp', ts + dur,
-    'thread_tid', tid, 'process_pid', pid,
-    'thread_name', thread_name))
+  SELECT AndroidStartupMetric_TraceThreadSectionInfo(
+    'start_timestamp', MIN(ts),
+    'end_timestamp', MAX(ts + dur),
+    'thread_section', RepeatedField(AndroidStartupMetric_TraceThreadSection(
+      'start_timestamp', ts, 'end_timestamp', ts + dur,
+      'thread_tid', tid, 'process_pid', pid,
+      'thread_name', thread_name)))
   FROM (
     SELECT p.pid, ts, dur, thread.tid, thread_name
     FROM launch_threads_by_thread_state l, android_startup_processes p
@@ -62,10 +65,13 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_main_thread_time_for_launch_and_state(
   startup_id LONG, state STRING, num_threads INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceThreadSection(
-    'start_timestamp', ts, 'end_timestamp', ts + dur,
-    'thread_tid', tid, 'process_pid', pid,
-    'thread_name', thread_name))
+  SELECT AndroidStartupMetric_TraceThreadSectionInfo(
+    'start_timestamp', MIN(ts),
+    'end_timestamp', MAX(ts + dur),
+    'thread_section', RepeatedField(AndroidStartupMetric_TraceThreadSection(
+      'start_timestamp', ts, 'end_timestamp', ts + dur,
+      'thread_tid', tid, 'process_pid', pid,
+      'thread_name', thread_name)))
   FROM (
     SELECT p.pid, ts, dur, thread.tid, thread_name
     FROM launch_threads_by_thread_state l, android_startup_processes p
@@ -78,10 +84,13 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_main_thread_time_for_launch_state_and_io_wait(
   startup_id INT, state STRING, io_wait BOOL, num_threads INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceThreadSection(
-    'start_timestamp', ts, 'end_timestamp', ts + dur,
-    'thread_tid', tid, 'process_pid', pid,
-    'thread_name', thread_name))
+  SELECT AndroidStartupMetric_TraceThreadSectionInfo(
+    'start_timestamp', MIN(ts),
+    'end_timestamp', MAX(ts + dur),
+    'thread_section', RepeatedField(AndroidStartupMetric_TraceThreadSection(
+      'start_timestamp', ts, 'end_timestamp', ts + dur,
+      'thread_tid', tid, 'process_pid', pid,
+      'thread_name', thread_name)))
   FROM (
     SELECT p.pid, ts, dur, thread.tid, thread_name
     FROM launch_threads_by_thread_state l, android_startup_processes p
@@ -95,10 +104,13 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_thread_time_for_launch_state_and_thread(
   startup_id INT, state STRING, thread_name STRING, num_threads INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceThreadSection(
-    'start_timestamp', ts, 'end_timestamp', ts + dur,
-    'thread_tid', tid, 'process_pid', pid,
-    'thread_name', thread_name))
+  SELECT AndroidStartupMetric_TraceThreadSectionInfo(
+    'start_timestamp', MIN(ts),
+    'end_timestamp', MAX(ts + dur),
+    'thread_section', RepeatedField(AndroidStartupMetric_TraceThreadSection(
+      'start_timestamp', ts, 'end_timestamp', ts + dur,
+      'thread_tid', tid, 'process_pid', pid,
+      'thread_name', thread_name)))
   FROM (
     SELECT p.pid, ts, dur, thread.tid, thread_name
     FROM launch_threads_by_thread_state l, android_startup_processes p
@@ -111,13 +123,16 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_missing_baseline_profile_for_launch(
   startup_id LONG, pkg_name STRING)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', pid,
-    'start_timestamp', slice_ts,
-    'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id,
-    'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', pid,
+      'start_timestamp', slice_ts,
+      'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id,
+      'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT p.pid, tid, slice_ts, slice_dur, slice_id, slice_name
     FROM ANDROID_SLICES_FOR_STARTUP_AND_SLICE_NAME($startup_id,
@@ -135,13 +150,16 @@
 
 CREATE OR REPLACE PERFETTO FUNCTION get_run_from_apk(startup_id LONG)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', pid,
-    'start_timestamp', slice_ts,
-    'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id,
-    'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', pid,
+      'start_timestamp', slice_ts,
+      'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id,
+      'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT p.pid, tid, slice_ts, slice_dur, slice_id, slice_name
     FROM android_thread_slices_for_all_startups l, android_startup_processes p
@@ -157,13 +175,16 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_unlock_running_during_launch_slice(startup_id LONG,
   pid INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', $pid,
-    'start_timestamp', slice_ts,
-    'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id,
-    'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', $pid,
+      'start_timestamp', slice_ts,
+      'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id,
+      'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT tid, slice.ts as slice_ts, slice.dur as slice_dur,
       slice.id as slice_id, slice.name as slice_name
@@ -180,13 +201,16 @@
 
 CREATE OR REPLACE PERFETTO FUNCTION get_gc_activity(startup_id LONG, num_slices INT)
 RETURNS PROTO  AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', pid,
-    'start_timestamp', slice_ts,
-    'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id,
-    'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', pid,
+      'start_timestamp', slice_ts,
+      'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id,
+      'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT p.pid, tid, slice_ts, slice_dur, slice_id, slice_name
     FROM android_thread_slices_for_all_startups slice, android_startup_processes p
@@ -204,13 +228,16 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_dur_on_main_thread_for_startup_and_slice(
   startup_id LONG, slice_name STRING, num_slices INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', pid,
-    'start_timestamp', slice_ts,
-    'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id,
-    'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', pid,
+      'start_timestamp', slice_ts,
+      'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id,
+      'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT p.pid, tid, slice_ts, slice_dur, slice_id, slice_name
     FROM android_thread_slices_for_all_startups l,
@@ -223,11 +250,14 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_main_thread_binder_transactions_blocked(
   startup_id LONG, threshold DOUBLE, num_slices INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', pid,
-    'start_timestamp', slice_ts, 'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id, 'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', pid,
+      'start_timestamp', slice_ts, 'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id, 'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT pid, request.tid as tid, request.slice_ts as slice_ts, request.slice_dur as slice_dur,
       request.id as slice_id, request.slice_name as slice_name
@@ -253,11 +283,14 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_slices_concurrent_to_launch(
   startup_id INT, slice_glob STRING, num_slices INT, pid INT)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', $pid,
-    'start_timestamp', ts, 'end_timestamp', ts + dur,
-    'slice_id', id, 'slice_name', name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', $pid,
+      'start_timestamp', ts, 'end_timestamp', ts + dur,
+      'slice_id', id, 'slice_name', name)),
+    'start_timestamp', MIN(ts),
+    'end_timestamp', MAX(ts + dur))
   FROM (
     SELECT thread.tid, s.ts as ts, dur, s.id, s.name FROM slice s
     JOIN thread_track t ON s.track_id = t.id
@@ -275,11 +308,14 @@
 CREATE OR REPLACE PERFETTO FUNCTION get_slices_for_startup_and_slice_name(
   startup_id INT, slice_name STRING, num_slices INT, pid int)
 RETURNS PROTO AS
-  SELECT RepeatedField(AndroidStartupMetric_TraceSliceSection(
-    'thread_tid', tid,
-    'process_pid', $pid,
-    'start_timestamp', slice_ts, 'end_timestamp', slice_ts + slice_dur,
-    'slice_id', slice_id, 'slice_name', slice_name))
+  SELECT AndroidStartupMetric_TraceSliceSectionInfo(
+    'slice_section', RepeatedField(AndroidStartupMetric_TraceSliceSection(
+      'thread_tid', tid,
+      'process_pid', $pid,
+      'start_timestamp', slice_ts, 'end_timestamp', slice_ts + slice_dur,
+      'slice_id', slice_id, 'slice_name', slice_name)),
+    'start_timestamp', MIN(slice_ts),
+    'end_timestamp', MAX(slice_ts + slice_dur))
   FROM (
     SELECT tid, slice_ts, slice_dur, slice_id, slice_name
     FROM android_thread_slices_for_all_startups
diff --git a/src/trace_processor/perfetto_sql/stdlib/android/critical_blocking_calls.sql b/src/trace_processor/perfetto_sql/stdlib/android/critical_blocking_calls.sql
index 90697fa..66d2d01 100644
--- a/src/trace_processor/perfetto_sql/stdlib/android/critical_blocking_calls.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/android/critical_blocking_calls.sql
@@ -39,6 +39,8 @@
   OR $name GLOB 'NotificationStackScrollLayout#onMeasure'
   OR $name GLOB 'ExpNotRow#*'
   OR $name GLOB 'GC: Wait For*'
+  OR $name GLOB 'Recomposer:*'
+  OR $name GLOB 'Compose:*'
   OR (
     -- Some top level handler slices
     $depth = 0
diff --git a/test/cmdline_integrationtest.cc b/test/cmdline_integrationtest.cc
index 2febd90..c4a6fc7 100644
--- a/test/cmdline_integrationtest.cc
+++ b/test/cmdline_integrationtest.cc
@@ -47,6 +47,7 @@
 
 using ::testing::ContainsRegex;
 using ::testing::Each;
+using ::testing::ElementsAre;
 using ::testing::ElementsAreArray;
 using ::testing::Eq;
 using ::testing::HasSubstr;
@@ -99,6 +100,52 @@
   return trace_config;
 }
 
+// For the regular tests.
+TraceConfig CreateTraceConfigForTest(uint32_t test_msg_count = 11,
+                                     uint32_t test_msg_size = 32) {
+  TraceConfig trace_config;
+  trace_config.add_buffers()->set_size_kb(1024);
+  auto* ds_config = trace_config.add_data_sources()->mutable_config();
+  ds_config->set_name("android.perfetto.FakeProducer");
+  ds_config->mutable_for_testing()->set_message_count(test_msg_count);
+  ds_config->mutable_for_testing()->set_message_size(test_msg_size);
+  return trace_config;
+}
+
+void ExpectTraceContainsTestMessages(const protos::gen::Trace& trace,
+                                     uint32_t count) {
+  ssize_t actual_test_packets_count = std::count_if(
+      trace.packet().begin(), trace.packet().end(),
+      [](const protos::gen::TracePacket& tp) { return tp.has_for_testing(); });
+  EXPECT_EQ(count, static_cast<uint32_t>(actual_test_packets_count));
+}
+
+void ExpectTraceContainsTestMessagesWithSize(const protos::gen::Trace& trace,
+                                             uint32_t message_size) {
+  for (const auto& packet : trace.packet()) {
+    if (packet.has_for_testing()) {
+      EXPECT_EQ(message_size, packet.for_testing().str().size());
+    }
+  }
+}
+
+void ExpectTraceContainsConfigWithTriggerMode(
+    const protos::gen::Trace& trace,
+    protos::gen::TraceConfig::TriggerConfig::TriggerMode trigger_mode) {
+  // GTest three level nested Property matcher is hard to read, so we use
+  // 'find_if' with lambda to ensure the trace config properly includes the
+  // trigger mode we set.
+  auto found =
+      std::find_if(trace.packet().begin(), trace.packet().end(),
+                   [trigger_mode](const protos::gen::TracePacket& tp) {
+                     return tp.has_trace_config() &&
+                            tp.trace_config().trigger_config().trigger_mode() ==
+                                trigger_mode;
+                   });
+  EXPECT_NE(found, trace.packet().end())
+      << "Trace config doesn't include expected trigger mode.";
+}
+
 class ScopedFileRemove {
  public:
   explicit ScopedFileRemove(const std::string& path) : path_(path) {}
@@ -106,6 +153,27 @@
   std::string path_;
 };
 
+bool ParseNotEmptyTraceFromFile(const std::string& trace_path,
+                                protos::gen::Trace& out) {
+  std::string trace_str;
+  if (!base::ReadFile(trace_path, &trace_str))
+    return false;
+  if (trace_str.empty())
+    return false;
+  return out.ParseFromString(trace_str);
+}
+
+std::vector<std::string> GetReceivedTriggerNames(
+    const protos::gen::Trace& trace) {
+  std::vector<std::string> triggers;
+  for (const protos::gen::TracePacket& packet : trace.packet()) {
+    if (packet.has_trigger()) {
+      triggers.push_back(packet.trigger().trigger_name());
+    }
+  }
+  return triggers;
+}
+
 class PerfettoCmdlineTest : public ::testing::Test {
  public:
   void StartServiceIfRequiredNoNewExecsAfterThis() {
@@ -190,11 +258,8 @@
       // Read the trace written in the fixed location
       // (/data/misc/perfetto-traces/ on Android, /tmp/ on Linux/Mac) and make
       // sure it has the right contents.
-      std::string trace_str;
-      base::ReadFile(trace_path, &trace_str);
-      ASSERT_FALSE(trace_str.empty());
       protos::gen::Trace trace;
-      ASSERT_TRUE(trace.ParseFromString(trace_str));
+      ASSERT_TRUE(ParseNotEmptyTraceFromFile(trace_path, trace));
       uint32_t test_packets = 0;
       for (const auto& p : trace.packet())
         test_packets += p.has_for_testing() ? 1 : 0;
@@ -212,6 +277,11 @@
   std::string stderr_;
   base::TestTaskRunner task_runner_;
 
+  // We use these two constants to set test data payload parameters and assert
+  // it was correctly written to the trace.
+  static constexpr size_t kTestMessageCount = 11;
+  static constexpr size_t kTestMessageSize = 32;
+
  private:
   bool exec_allowed_ = true;
   TestHelper test_helper_{&task_runner_};
@@ -350,15 +420,8 @@
 }
 
 TEST_F(PerfettoCmdlineTest, StartTracingTrigger) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 11;
-  constexpr size_t kMessageSize = 32;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::START_TRACING);
@@ -404,53 +467,25 @@
   test_helper().WaitForProducerSetup();
   EXPECT_EQ(0, trigger_proc.Run(&stderr_));
 
-  // Wait for the producer to start, and then write out 11 packets.
+  // Wait for the producer to start, and then write out some test packets.
   test_helper().WaitForProducerEnabled();
   auto on_data_written = task_runner_.CreateCheckpoint("data_written");
   fake_producer->ProduceEventBatch(test_helper().WrapTask(on_data_written));
   task_runner_.RunUntilCheckpoint("data_written");
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  size_t for_testing_packets = 0;
-  size_t trigger_packets = 0;
-  size_t trace_config_packets = 0;
-  for (const auto& packet : trace.packet()) {
-    if (packet.has_trace_config()) {
-      // Ensure the trace config properly includes the trigger mode we set.
-      auto kStartTrig = protos::gen::TraceConfig::TriggerConfig::START_TRACING;
-      EXPECT_EQ(kStartTrig,
-                packet.trace_config().trigger_config().trigger_mode());
-      ++trace_config_packets;
-    } else if (packet.has_trigger()) {
-      // validate that the triggers are properly added to the trace.
-      EXPECT_EQ("trigger_name", packet.trigger().trigger_name());
-      ++trigger_packets;
-    } else if (packet.has_for_testing()) {
-      // Make sure that the data size is correctly set based on what we
-      // requested.
-      EXPECT_EQ(kMessageSize, packet.for_testing().str().size());
-      ++for_testing_packets;
-    }
-  }
-  EXPECT_EQ(trace_config_packets, 1u);
-  EXPECT_EQ(trigger_packets, 1u);
-  EXPECT_EQ(for_testing_packets, kMessageCount);
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path, trace));
+  ExpectTraceContainsConfigWithTriggerMode(
+      trace, protos::gen::TraceConfig::TriggerConfig::START_TRACING);
+  EXPECT_THAT(GetReceivedTriggerNames(trace), ElementsAre("trigger_name"));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
 }
 
 TEST_F(PerfettoCmdlineTest, StopTracingTrigger) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 11;
-  constexpr size_t kMessageSize = 32;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
@@ -497,8 +532,8 @@
   });
 
   test_helper().WaitForProducerEnabled();
-  // Wait for the producer to start, and then write out 11 packets, before the
-  // trace actually starts (the trigger is seen).
+  // Wait for the producer to start, and then write out some test packets,
+  // before the trace actually starts (the trigger is seen).
   auto on_data_written = task_runner_.CreateCheckpoint("data_written_1");
   fake_producer->ProduceEventBatch(test_helper().WrapTask(on_data_written));
   task_runner_.RunUntilCheckpoint("data_written_1");
@@ -507,56 +542,23 @@
 
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  bool seen_first_trigger = false;
-  size_t for_testing_packets = 0;
-  size_t trigger_packets = 0;
-  size_t trace_config_packets = 0;
-  for (const auto& packet : trace.packet()) {
-    if (packet.has_trace_config()) {
-      // Ensure the trace config properly includes the trigger mode we set.
-      auto kStopTrig = protos::gen::TraceConfig::TriggerConfig::STOP_TRACING;
-      EXPECT_EQ(kStopTrig,
-                packet.trace_config().trigger_config().trigger_mode());
-      ++trace_config_packets;
-    } else if (packet.has_trigger()) {
-      // validate that the triggers are properly added to the trace.
-      if (!seen_first_trigger) {
-        EXPECT_EQ("trigger_name", packet.trigger().trigger_name());
-        seen_first_trigger = true;
-      } else {
-        EXPECT_EQ("trigger_name_3", packet.trigger().trigger_name());
-      }
-      ++trigger_packets;
-    } else if (packet.has_for_testing()) {
-      // Make sure that the data size is correctly set based on what we
-      // requested.
-      EXPECT_EQ(kMessageSize, packet.for_testing().str().size());
-      ++for_testing_packets;
-    }
-  }
-  EXPECT_EQ(trace_config_packets, 1u);
-  EXPECT_EQ(trigger_packets, 2u);
-  EXPECT_EQ(for_testing_packets, kMessageCount);
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path, trace));
+  ExpectTraceContainsConfigWithTriggerMode(
+      trace, protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
+  EXPECT_THAT(GetReceivedTriggerNames(trace),
+              ElementsAre("trigger_name", "trigger_name_3"));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
 }
 
 // Dropbox on the commandline client only works on android builds. So disable
 // this test on all other builds.
 TEST_F(PerfettoCmdlineTest, AndroidOnly(NoDataNoFileWithoutTrigger)) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 11;
-  constexpr size_t kMessageSize = 32;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* incident_config = trace_config.mutable_incident_report_config();
   incident_config->set_destination_package("foo.bar.baz");
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
@@ -601,15 +603,8 @@
 }
 
 TEST_F(PerfettoCmdlineTest, StopTracingTriggerFromConfig) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 11;
-  constexpr size_t kMessageSize = 32;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
@@ -666,8 +661,8 @@
   });
 
   test_helper().WaitForProducerEnabled();
-  // Wait for the producer to start, and then write out 11 packets, before the
-  // trace actually starts (the trigger is seen).
+  // Wait for the producer to start, and then write out some test packets,
+  // before the trace actually starts (the trigger is seen).
   auto on_data_written = task_runner_.CreateCheckpoint("data_written_1");
   fake_producer->ProduceEventBatch(test_helper().WrapTask(on_data_written));
   task_runner_.RunUntilCheckpoint("data_written_1");
@@ -676,44 +671,20 @@
 
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  EXPECT_LT(static_cast<int>(kMessageCount), trace.packet_size());
-  bool seen_first_trigger = false;
-  for (const auto& packet : trace.packet()) {
-    if (packet.has_trace_config()) {
-      // Ensure the trace config properly includes the trigger mode we set.
-      auto kStopTrig = protos::gen::TraceConfig::TriggerConfig::STOP_TRACING;
-      EXPECT_EQ(kStopTrig,
-                packet.trace_config().trigger_config().trigger_mode());
-    } else if (packet.has_trigger()) {
-      // validate that the triggers are properly added to the trace.
-      if (!seen_first_trigger) {
-        EXPECT_EQ("trigger_name", packet.trigger().trigger_name());
-        seen_first_trigger = true;
-      } else {
-        EXPECT_EQ("trigger_name_3", packet.trigger().trigger_name());
-      }
-    } else if (packet.has_for_testing()) {
-      // Make sure that the data size is correctly set based on what we
-      // requested.
-      EXPECT_EQ(kMessageSize, packet.for_testing().str().size());
-    }
-  }
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path, trace));
+  EXPECT_LT(static_cast<int>(kTestMessageCount), trace.packet_size());
+  ExpectTraceContainsConfigWithTriggerMode(
+      trace, protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
+  EXPECT_THAT(GetReceivedTriggerNames(trace),
+              ElementsAre("trigger_name", "trigger_name_3"));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
 }
 
 TEST_F(PerfettoCmdlineTest, TriggerFromConfigStopsFileOpening) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 11;
-  constexpr size_t kMessageSize = 32;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
@@ -772,15 +743,8 @@
 }
 
 TEST_F(PerfettoCmdlineTest, AndroidOnly(CmdTriggerWithUploadFlag)) {
-  // See |message_count| and |message_size| in the TraceConfig above.
-  constexpr size_t kMessageCount = 2;
-  constexpr size_t kMessageSize = 2;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::STOP_TRACING);
@@ -831,8 +795,8 @@
   });
 
   test_helper().WaitForProducerEnabled();
-  // Wait for the producer to start, and then write out 11 packets, before the
-  // trace actually starts (the trigger is seen).
+  // Wait for the producer to start, and then write out some test packets,
+  // before the trace actually starts (the trigger is seen).
   auto on_data_written = task_runner_.CreateCheckpoint("data_written_1");
   fake_producer->ProduceEventBatch(test_helper().WrapTask(on_data_written));
   task_runner_.RunUntilCheckpoint("data_written_1");
@@ -841,11 +805,11 @@
 
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  EXPECT_LT(static_cast<int>(kMessageCount), trace.packet_size());
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path, trace));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
+  EXPECT_LT(static_cast<int>(kTestMessageCount), trace.packet_size());
   EXPECT_THAT(trace.packet(),
               Contains(Property(&protos::gen::TracePacket::trigger,
                                 Property(&protos::gen::Trigger::trigger_name,
@@ -853,14 +817,8 @@
 }
 
 TEST_F(PerfettoCmdlineTest, TriggerCloneSnapshot) {
-  constexpr size_t kMessageCount = 2;
-  constexpr size_t kMessageSize = 2;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(kMessageSize);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   auto* trigger_cfg = trace_config.mutable_trigger_config();
   trigger_cfg->set_trigger_mode(
       protos::gen::TraceConfig::TriggerConfig::CLONE_SNAPSHOT);
@@ -910,8 +868,8 @@
   });
 
   test_helper().WaitForProducerEnabled();
-  // Wait for the producer to start, and then write out 11 packets, before the
-  // trace actually starts (the trigger is seen).
+  // Wait for the producer to start, and then write out some test packets,
+  // before the trace actually starts (the trigger is seen).
   auto on_data_written = task_runner_.CreateCheckpoint("data_written_1");
   fake_producer->ProduceEventBatch(test_helper().WrapTask(on_data_written));
   task_runner_.RunUntilCheckpoint("data_written_1");
@@ -931,11 +889,11 @@
   perfetto_proc.SendSigterm();
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(snapshot_path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  EXPECT_LT(static_cast<int>(kMessageCount), trace.packet_size());
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(snapshot_path, trace));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
+  EXPECT_LT(static_cast<int>(kTestMessageCount), trace.packet_size());
   EXPECT_THAT(trace.packet(),
               Contains(Property(&protos::gen::TracePacket::trigger,
                                 Property(&protos::gen::Trigger::trigger_name,
@@ -961,14 +919,9 @@
 }
 
 TEST_F(PerfettoCmdlineTest, CloneByName) {
-  constexpr size_t kMessageCount = 2;
-  protos::gen::TraceConfig trace_config;
-  trace_config.add_buffers()->set_size_kb(1024);
+  protos::gen::TraceConfig trace_config =
+      CreateTraceConfigForTest(kTestMessageCount, kTestMessageSize);
   trace_config.set_unique_session_name("my_unique_session_name");
-  auto* ds_config = trace_config.add_data_sources()->mutable_config();
-  ds_config->set_name("android.perfetto.FakeProducer");
-  ds_config->mutable_for_testing()->set_message_count(kMessageCount);
-  ds_config->mutable_for_testing()->set_message_size(2);
 
   // We have to construct all the processes we want to fork before we start the
   // service with |StartServiceIfRequired()|. this is because it is unsafe
@@ -1026,26 +979,18 @@
   EXPECT_EQ(0, perfetto_proc_clone_2.Run(&stderr_)) << "stderr: " << stderr_;
   EXPECT_FALSE(base::FileExists(path_cloned_2));
 
-  std::string cloned_trace_str;
-  base::ReadFile(path_cloned, &cloned_trace_str);
   protos::gen::Trace cloned_trace;
-  ASSERT_TRUE(cloned_trace.ParseFromString(cloned_trace_str));
-  ssize_t cloned_num_test_packets = std::count_if(
-      cloned_trace.packet().begin(), cloned_trace.packet().end(),
-      [](const protos::gen::TracePacket& tp) { return tp.has_for_testing(); });
-  EXPECT_EQ(cloned_num_test_packets, static_cast<ssize_t>(kMessageCount));
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path_cloned, cloned_trace));
+  ExpectTraceContainsTestMessages(cloned_trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(cloned_trace, kTestMessageSize);
 
   perfetto_proc.SendSigterm();
   background_trace.join();
 
-  std::string trace_str;
-  base::ReadFile(path, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str));
-  ssize_t num_test_packets = std::count_if(
-      trace.packet().begin(), trace.packet().end(),
-      [](const protos::gen::TracePacket& tp) { return tp.has_for_testing(); });
-  EXPECT_EQ(num_test_packets, static_cast<ssize_t>(kMessageCount));
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(path, trace));
+  ExpectTraceContainsTestMessages(trace, kTestMessageCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kTestMessageSize);
 }
 
 // Regression test for b/279753347: --save-for-bugreport would create an empty
@@ -1185,10 +1130,8 @@
   auto check_trace = [&](std::string fname, int expected_score) {
     std::string fpath = GetBugreportTraceDir() + "/" + fname;
     ASSERT_TRUE(base::FileExists(fpath)) << fpath;
-    std::string trace_str;
-    base::ReadFile(fpath, &trace_str);
     protos::gen::Trace trace;
-    ASSERT_TRUE(trace.ParseFromString(trace_str)) << fpath;
+    ASSERT_TRUE(ParseNotEmptyTraceFromFile(fpath, trace)) << fpath;
     EXPECT_THAT(
         trace.packet(),
         Contains(Property(&protos::gen::TracePacket::trace_config,
@@ -1211,8 +1154,9 @@
   auto remove_on_exit = base::OnScopeExit(remove_br_files);
 
   const uint32_t kMsgCount = 10000;
+  const uint32_t kMsgSize = 1024;
   TraceConfig cfg = CreateTraceConfigForBugreportTest(
-      /*score=*/1, /*add_filter=*/false, kMsgCount, /*msg_size=*/1024);
+      /*score=*/1, /*add_filter=*/false, kMsgCount, kMsgSize);
 
   auto session_name = "bugreport_test_" +
                       std::to_string(base::GetWallTimeNs().count() % 1000000);
@@ -1263,14 +1207,10 @@
 
   std::string fpath = GetBugreportTraceDir() + "/systrace.pftrace";
   ASSERT_TRUE(base::FileExists(fpath)) << fpath;
-  std::string trace_str;
-  base::ReadFile(fpath, &trace_str);
   protos::gen::Trace trace;
-  ASSERT_TRUE(trace.ParseFromString(trace_str)) << fpath;
-  ssize_t num_test_packets = std::count_if(
-      trace.packet().begin(), trace.packet().end(),
-      [](const protos::gen::TracePacket& tp) { return tp.has_for_testing(); });
-  EXPECT_EQ(num_test_packets, static_cast<ssize_t>(kMsgCount));
+  ASSERT_TRUE(ParseNotEmptyTraceFromFile(fpath, trace)) << fpath;
+  ExpectTraceContainsTestMessages(trace, kMsgCount);
+  ExpectTraceContainsTestMessagesWithSize(trace, kMsgSize);
 }
 
 }  // namespace perfetto
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup.out b/test/trace_processor/diff_tests/metrics/startup/android_startup.out
index 42368b3..ec2b075 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup.out
@@ -78,11 +78,15 @@
       }
       launch_dur: 108
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 130
+          end_timestamp: 210
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 130
         end_timestamp: 210
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     startup_type: "warm"
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution.out
index 2d6070c..8b53348 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution.out
@@ -148,12 +148,16 @@
       }
       launch_dur: 999999900
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 340
+          end_timestamp: 390
+          slice_id: 20
+          slice_name: "CollectorTransition mark sweep GC"
+          process_pid: 3
+          thread_tid: 5
+        }
         start_timestamp: 340
         end_timestamp: 390
-        slice_id: 20
-        slice_name: "CollectorTransition mark sweep GC"
-        process_pid: 3
-        thread_tid: 5
       }
     }
     slow_start_reason_with_details {
@@ -171,20 +175,24 @@
       }
       launch_dur: 999999900
       trace_slice_sections {
-        start_timestamp: 170
-        end_timestamp: 500000000
-        slice_id: 9
-        slice_name: "OpenDexFilesFromOat(something else)"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
+        slice_section {
+          start_timestamp: 170
+          end_timestamp: 500000000
+          slice_id: 9
+          slice_name: "OpenDexFilesFromOat(something else)"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 150
+          end_timestamp: 165
+          slice_id: 5
+          slice_name: "OpenDexFilesFromOat(something)"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 150
-        end_timestamp: 165
-        slice_id: 5
-        slice_name: "OpenDexFilesFromOat(something)"
-        process_pid: 3
-        thread_tid: 3
+        end_timestamp: 500000000
       }
     }
     slow_start_reason_with_details {
@@ -200,12 +208,16 @@
       }
       launch_dur: 999999900
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 10000000
+          end_timestamp: 50000000
+          slice_id: 21
+          slice_name: "binder transaction"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 10000000
         end_timestamp: 50000000
-        slice_id: 21
-        slice_name: "binder transaction"
-        process_pid: 3
-        thread_tid: 3
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution_slow.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution_slow.out
index a98505f..a4e1074 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution_slow.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_attribution_slow.out
@@ -107,12 +107,16 @@
       }
       launch_dur: 999999900000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 340000000000
+          end_timestamp: 390000000000
+          slice_id: 91
+          slice_name: "CollectorTransition mark sweep GC"
+          process_pid: 3
+          thread_tid: 5
+        }
         start_timestamp: 340000000000
         end_timestamp: 390000000000
-        slice_id: 91
-        slice_name: "CollectorTransition mark sweep GC"
-        process_pid: 3
-        thread_tid: 5
       }
     }
     slow_start_reason_with_details {
@@ -129,25 +133,29 @@
       }
       launch_dur: 999999900000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 155000000000
+          end_timestamp: 165000000000
+          thread_name: "Jit thread pool"
+          process_pid: 3
+          thread_tid: 4
+        }
+        thread_section {
+          start_timestamp: 170000000000
+          end_timestamp: 175000000000
+          thread_name: "Jit thread pool"
+          process_pid: 3
+          thread_tid: 4
+        }
+        thread_section {
+          start_timestamp: 185000000000
+          end_timestamp: 190000000000
+          thread_name: "Jit thread pool"
+          process_pid: 3
+          thread_tid: 4
+        }
         start_timestamp: 155000000000
-        end_timestamp: 165000000000
-        thread_name: "Jit thread pool"
-        thread_tid: 4
-        process_pid: 3
-      }
-      trace_thread_sections {
-        start_timestamp: 170000000000
-        end_timestamp: 175000000000
-        thread_name: "Jit thread pool"
-        thread_tid: 4
-        process_pid: 3
-      }
-      trace_thread_sections {
-        start_timestamp: 185000000000
         end_timestamp: 190000000000
-        thread_name: "Jit thread pool"
-        thread_tid: 4
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -164,28 +172,32 @@
       }
       launch_dur: 999999900000000000
       trace_slice_sections {
-        start_timestamp: 200000000000
-        end_timestamp: 210000000000
-        slice_id: 84
-        slice_name: "JIT compiling nothing"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
+        slice_section {
+          start_timestamp: 200000000000
+          end_timestamp: 210000000000
+          slice_id: 84
+          slice_name: "JIT compiling nothing"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 100000000000
+          end_timestamp: 101000000000
+          slice_id: 9
+          slice_name: "JIT compiling something"
+          process_pid: 3
+          thread_tid: 4
+        }
+        slice_section {
+          start_timestamp: 101000000000
+          end_timestamp: 102000000000
+          slice_id: 10
+          slice_name: "JIT compiling something"
+          process_pid: 3
+          thread_tid: 4
+        }
         start_timestamp: 100000000000
-        end_timestamp: 101000000000
-        slice_id: 9
-        slice_name: "JIT compiling something"
-        process_pid: 3
-        thread_tid: 4
-      }
-      trace_slice_sections {
-        start_timestamp: 101000000000
-        end_timestamp: 102000000000
-        slice_id: 10
-        slice_name: "JIT compiling something"
-        process_pid: 3
-        thread_tid: 4
+        end_timestamp: 210000000000
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown.out
index 2405d7e..04b8915 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown.out
@@ -128,12 +128,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 204000000000
+          end_timestamp: 205000000000
+          slice_id: 13
+          slice_name: "location=/system/framework/oat/arm/com.google.android.calendar.odex status=up-to-date filter=speed reason=install-dm"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 204000000000
         end_timestamp: 205000000000
-        slice_id: 13
-        slice_name: "location=/system/framework/oat/arm/com.google.android.calendar.odex status=up-to-date filter=speed reason=install-dm"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -149,12 +153,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 200000000000
+          end_timestamp: 202000000000
+          slice_id: 12
+          slice_name: "location=error status=io-error-no-oat filter=run-from-apk reason=unknown"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 200000000000
         end_timestamp: 202000000000
-        slice_id: 12
-        slice_name: "location=error status=io-error-no-oat filter=run-from-apk reason=unknown"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -171,12 +179,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 185000000000
+          end_timestamp: 187000000000
+          slice_id: 4
+          slice_name: "bindApplication"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 185000000000
         end_timestamp: 187000000000
-        slice_id: 4
-        slice_name: "bindApplication"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -193,20 +205,24 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 188000000000
+          end_timestamp: 189000000000
+          slice_id: 6
+          slice_name: "inflate"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 191000000000
+          end_timestamp: 192000000000
+          slice_id: 8
+          slice_name: "inflate"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 188000000000
-        end_timestamp: 189000000000
-        slice_id: 6
-        slice_name: "inflate"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
-        start_timestamp: 191000000000
         end_timestamp: 192000000000
-        slice_id: 8
-        slice_name: "inflate"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -223,12 +239,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 188000000000
+          end_timestamp: 189000000000
+          slice_id: 7
+          slice_name: "ResourcesManager#getResources"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 188000000000
         end_timestamp: 189000000000
-        slice_id: 7
-        slice_name: "ResourcesManager#getResources"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -245,11 +265,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 205000000000
+          end_timestamp: 210000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 205000000000
         end_timestamp: 210000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     startup_type: "cold"
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown_slow.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown_slow.out
index 3dbb2aa..f2c7123 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown_slow.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_breakdown_slow.out
@@ -127,12 +127,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 200000000000
+          end_timestamp: 202000000000
+          slice_id: 12
+          slice_name: "location=error status=io-error-no-oat filter=run-from-apk reason=unknown"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 200000000000
         end_timestamp: 202000000000
-        slice_id: 12
-        slice_name: "location=error status=io-error-no-oat filter=run-from-apk reason=unknown"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -149,12 +153,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 185000000000
+          end_timestamp: 195000000000
+          slice_id: 4
+          slice_name: "bindApplication"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 185000000000
         end_timestamp: 195000000000
-        slice_id: 4
-        slice_name: "bindApplication"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -171,20 +179,24 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
-        start_timestamp: 190000000000
-        end_timestamp: 192000000000
-        slice_id: 8
-        slice_name: "inflate"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
+        slice_section {
+          start_timestamp: 190000000000
+          end_timestamp: 192000000000
+          slice_id: 8
+          slice_name: "inflate"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 188000000000
+          end_timestamp: 189000000000
+          slice_id: 7
+          slice_name: "inflate"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 188000000000
-        end_timestamp: 189000000000
-        slice_id: 7
-        slice_name: "inflate"
-        process_pid: 3
-        thread_tid: 3
+        end_timestamp: 192000000000
       }
     }
     slow_start_reason_with_details {
@@ -201,12 +213,16 @@
       }
       launch_dur: 108000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 187000000000
+          end_timestamp: 192000000000
+          slice_id: 5
+          slice_name: "ResourcesManager#getResources"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 187000000000
         end_timestamp: 192000000000
-        slice_id: 5
-        slice_name: "ResourcesManager#getResources"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -223,11 +239,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 205000000000
+          end_timestamp: 210000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 205000000000
         end_timestamp: 210000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     startup_type: "cold"
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_broadcast_multiple.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_broadcast_multiple.out
index 4913852..6cb97e0 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_broadcast_multiple.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_broadcast_multiple.out
@@ -46,25 +46,29 @@
       }
       launch_dur: 100
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 105
+          end_timestamp: 106
+          slice_id: 6
+          slice_name: "Broadcast dispatched from android (2005:system/1000) x"
+          thread_tid: 1
+        }
+        slice_section {
+          start_timestamp: 106
+          end_timestamp: 107
+          slice_id: 8
+          slice_name: "Broadcast dispatched from android (2005:system/1000) x"
+          thread_tid: 1
+        }
+        slice_section {
+          start_timestamp: 107
+          end_timestamp: 108
+          slice_id: 10
+          slice_name: "Broadcast dispatched from android (2005:system/1000) x"
+          thread_tid: 1
+        }
         start_timestamp: 105
-        end_timestamp: 106
-        slice_id: 6
-        slice_name: "Broadcast dispatched from android (2005:system/1000) x"
-        thread_tid: 1
-      }
-      trace_slice_sections {
-        start_timestamp: 106
-        end_timestamp: 107
-        slice_id: 8
-        slice_name: "Broadcast dispatched from android (2005:system/1000) x"
-        thread_tid: 1
-      }
-      trace_slice_sections {
-        start_timestamp: 107
         end_timestamp: 108
-        slice_id: 10
-        slice_name: "Broadcast dispatched from android (2005:system/1000) x"
-        thread_tid: 1
       }
     }
     slow_start_reason_with_details {
@@ -81,25 +85,29 @@
       }
       launch_dur: 100
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 100
+          end_timestamp: 101
+          slice_id: 1
+          slice_name: "broadcastReceiveReg: x"
+          thread_tid: 2
+        }
+        slice_section {
+          start_timestamp: 101
+          end_timestamp: 102
+          slice_id: 2
+          slice_name: "broadcastReceiveReg: x"
+          thread_tid: 2
+        }
+        slice_section {
+          start_timestamp: 102
+          end_timestamp: 103
+          slice_id: 3
+          slice_name: "broadcastReceiveReg: x"
+          thread_tid: 2
+        }
         start_timestamp: 100
-        end_timestamp: 101
-        slice_id: 1
-        slice_name: "broadcastReceiveReg: x"
-        thread_tid: 2
-      }
-      trace_slice_sections {
-        start_timestamp: 101
-        end_timestamp: 102
-        slice_id: 2
-        slice_name: "broadcastReceiveReg: x"
-        thread_tid: 2
-      }
-      trace_slice_sections {
-        start_timestamp: 102
         end_timestamp: 103
-        slice_id: 3
-        slice_name: "broadcastReceiveReg: x"
-        thread_tid: 2
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_lock_contention_slow.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_lock_contention_slow.out
index dabbb5f..f64dceb 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_lock_contention_slow.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_lock_contention_slow.out
@@ -82,12 +82,16 @@
       }
       launch_dur: 100000000000
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 112000000000
+          end_timestamp: 115000000000
+          slice_id: 1
+          slice_name: "bindApplication"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 112000000000
         end_timestamp: 115000000000
-        slice_id: 1
-        slice_name: "bindApplication"
-        process_pid: 3
-        thread_tid: 3
       }
     }
     slow_start_reason_with_details {
@@ -105,28 +109,32 @@
       }
       launch_dur: 100000000000
       trace_slice_sections {
-        start_timestamp: 140000000000
-        end_timestamp: 157000000000
-        slice_id: 5
-        slice_name: "Lock contention on a monitor lock (owner tid: 2)"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
+        slice_section {
+          start_timestamp: 140000000000
+          end_timestamp: 157000000000
+          slice_id: 5
+          slice_name: "Lock contention on a monitor lock (owner tid: 2)"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 120000000000
+          end_timestamp: 130000000000
+          slice_id: 4
+          slice_name: "Lock contention on thread list lock (owner tid: 2)"
+          process_pid: 3
+          thread_tid: 3
+        }
+        slice_section {
+          start_timestamp: 155000000000
+          end_timestamp: 160000000000
+          slice_id: 6
+          slice_name: "Lock contention on a monitor lock (owner tid: 3)"
+          process_pid: 3
+          thread_tid: 4
+        }
         start_timestamp: 120000000000
-        end_timestamp: 130000000000
-        slice_id: 4
-        slice_name: "Lock contention on thread list lock (owner tid: 2)"
-        process_pid: 3
-        thread_tid: 3
-      }
-      trace_slice_sections {
-        start_timestamp: 155000000000
         end_timestamp: 160000000000
-        slice_id: 6
-        slice_name: "Lock contention on a monitor lock (owner tid: 3)"
-        process_pid: 3
-        thread_tid: 4
       }
     }
     slow_start_reason_with_details {
@@ -144,20 +152,24 @@
      }
      launch_dur: 100000000000
      trace_slice_sections {
+       slice_section {
+         start_timestamp: 140000000000
+         end_timestamp: 157000000000
+         slice_id: 5
+         slice_name: "Lock contention on a monitor lock (owner tid: 2)"
+         process_pid: 3
+         thread_tid: 3
+       }
+       slice_section {
+         start_timestamp: 155000000000
+         end_timestamp: 160000000000
+         slice_id: 6
+         slice_name: "Lock contention on a monitor lock (owner tid: 3)"
+         process_pid: 3
+         thread_tid: 4
+       }
        start_timestamp: 140000000000
-       end_timestamp: 157000000000
-       slice_id: 5
-       slice_name: "Lock contention on a monitor lock (owner tid: 2)"
-       process_pid: 3
-       thread_tid: 3
-     }
-     trace_slice_sections {
-       start_timestamp: 155000000000
        end_timestamp: 160000000000
-       slice_id: 6
-       slice_name: "Lock contention on a monitor lock (owner tid: 3)"
-       process_pid: 3
-       thread_tid: 4
      }
     }
     startup_type: "cold"
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_process_track.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_process_track.out
index ded275e..17d426d 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_process_track.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_process_track.out
@@ -77,11 +77,15 @@
       }
       launch_dur: 7
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 103
+          end_timestamp: 107
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 103
         end_timestamp: 107
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
   }
@@ -164,11 +168,15 @@
       }
       launch_dur: 7
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 203
+          end_timestamp: 207
+          thread_name: "com.google.android.calendar"
+          process_pid: 4
+          thread_tid: 4
+        }
         start_timestamp: 203
         end_timestamp: 207
-        thread_name: "com.google.android.calendar"
-        thread_tid: 4
-        process_pid: 4
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_slow.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_slow.out
index 942037e..0ae5b29 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_slow.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_slow.out
@@ -81,11 +81,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 130000000000
+          end_timestamp: 210000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 130000000000
         end_timestamp: 210000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -102,11 +106,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 120000000000
+          end_timestamp: 125000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 120000000000
         end_timestamp: 125000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -123,11 +131,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 125000000000
+          end_timestamp: 130000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 125000000000
         end_timestamp: 130000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
     slow_start_reason_with_details {
@@ -144,11 +156,15 @@
       }
       launch_dur: 108000000000
       trace_thread_sections {
+        thread_section {
+          start_timestamp: 130000000000
+          end_timestamp: 210000000000
+          thread_name: "com.google.android.calendar"
+          process_pid: 3
+          thread_tid: 3
+        }
         start_timestamp: 130000000000
         end_timestamp: 210000000000
-        thread_name: "com.google.android.calendar"
-        thread_tid: 3
-        process_pid: 3
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/metrics/startup/android_startup_unlock.out b/test/trace_processor/diff_tests/metrics/startup/android_startup_unlock.out
index 3f52432..1f2000d 100644
--- a/test/trace_processor/diff_tests/metrics/startup/android_startup_unlock.out
+++ b/test/trace_processor/diff_tests/metrics/startup/android_startup_unlock.out
@@ -44,11 +44,15 @@
       }
       launch_dur: 100
       trace_slice_sections {
+        slice_section {
+          start_timestamp: 130
+          end_timestamp: 133
+          slice_id: 1
+          slice_name: "KeyguardUpdateMonitor#onAuthenticationSucceeded"
+          thread_tid: 2
+        }
         start_timestamp: 130
         end_timestamp: 133
-        slice_id: 1
-        slice_name: "KeyguardUpdateMonitor#onAuthenticationSucceeded"
-        thread_tid: 2
       }
     }
   }
diff --git a/test/trace_processor/diff_tests/parser/parsing/tests.py b/test/trace_processor/diff_tests/parser/parsing/tests.py
index 54707c1..8af739d 100644
--- a/test/trace_processor/diff_tests/parser/parsing/tests.py
+++ b/test/trace_processor/diff_tests/parser/parsing/tests.py
@@ -1570,3 +1570,76 @@
         5230422153284,0,1306,"[NULL]"
         5230425693562,0,10,1
         """))
+
+  # Kernel idle tasks created by /sbin/init should be filtered.
+  def test_task_newtask_swapper_by_init(self):
+    return DiffTestBlueprint(
+        trace=TextProto(r"""
+        packet {
+          first_packet_on_sequence: true
+          ftrace_events {
+            cpu: 1
+            event {
+              timestamp: 1000000
+              pid: 0
+              task_newtask {
+                pid: 1
+                comm: "swapper/0"
+                clone_flags: 8389376
+                oom_score_adj: 0
+              }
+            }
+            event {
+              timestamp: 1000000
+              pid: 0
+              task_newtask {
+                pid: 2
+                comm: "swapper/0"
+                clone_flags: 8390400
+                oom_score_adj: 0
+              }
+            }
+            event {
+              timestamp: 17000000
+              pid: 1
+              task_newtask {
+                pid: 0
+                comm: "swapper/0"
+                clone_flags: 256
+                oom_score_adj: 0
+              }
+            }
+            event {
+              timestamp: 17000000
+              pid: 1
+              task_newtask {
+                pid: 0
+                comm: "swapper/0"
+                clone_flags: 256
+                oom_score_adj: 0
+              }
+            }
+            event {
+              timestamp: 17000000
+              pid: 1
+              task_newtask {
+                pid: 0
+                comm: "swapper/0"
+                clone_flags: 256
+                oom_score_adj: 0
+              }
+            }
+          }
+          trusted_uid: 9999
+          trusted_packet_sequence_id: 2
+          trusted_pid: 521
+          previous_packet_dropped: true
+        }
+        """),
+        query="""
+        SELECT utid, tid, name from thread where tid = 0
+        """,
+        out=Csv("""
+        "utid","tid","name"
+        0,0,"swapper"
+        """))
diff --git a/test/trace_processor/diff_tests/parser/power/tests_linux_sysfs_power.py b/test/trace_processor/diff_tests/parser/power/tests_linux_sysfs_power.py
index 046d1c4..ccdae4f 100644
--- a/test/trace_processor/diff_tests/parser/power/tests_linux_sysfs_power.py
+++ b/test/trace_processor/diff_tests/parser/power/tests_linux_sysfs_power.py
@@ -147,7 +147,7 @@
         packet {
           timestamp: 4000000
           battery {
-            current_ua: 510000
+            current_ua: -510000
             voltage_uv: 12000000
           }
         }
diff --git a/tools/gen_bazel b/tools/gen_bazel
index a30f5ca..a5776ba 100755
--- a/tools/gen_bazel
+++ b/tools/gen_bazel
@@ -83,12 +83,12 @@
     '//src/trace_processor:trace_processor_shell',
     '//src/trace_processor:trace_processor',
     '//src/traceconv:traceconv',
-    '//src/traceconv:libpprofbuilder',
 ]
 
 # These targets will be exported with visibility only to our allowlist.
 allowlist_public_targets = [
     '//src/shared_lib:libperfetto_c',
+    '//src/traceconv:libpprofbuilder',
 ]
 
 # These targets are required by internal build rules but don't need to be
diff --git a/ui/release/channels.json b/ui/release/channels.json
index c2c76c2..5f8615b 100644
--- a/ui/release/channels.json
+++ b/ui/release/channels.json
@@ -6,7 +6,7 @@
     },
     {
       "name": "canary",
-      "rev": "4817ff8af4289f905c36a8a1ba6a583afc569af4"
+      "rev": "2db61efa59d1e2eecb6975854c14b2a122fbfa8a"
     },
     {
       "name": "autopush",
diff --git a/ui/src/base/mithril_utils.ts b/ui/src/base/mithril_utils.ts
index b450f64..9b0615d 100644
--- a/ui/src/base/mithril_utils.ts
+++ b/ui/src/base/mithril_utils.ts
@@ -52,3 +52,36 @@
     );
   },
 };
+
+/**
+ * Utility function to pre-bind some mithril attrs of a component, and leave
+ * the others unbound and passed at run-time.
+ * Example use case: the Page API Passes to the registered page a PageAttrs,
+ * which is {subpage:string}. Imagine you write a MyPage component that takes
+ * some extra input attrs (e.g. the App object) and you want to bind them
+ * onActivate(). The results looks like this:
+ *
+ * interface MyPageAttrs extends PageAttrs { app: App; }
+ *
+ * class MyPage extends m.classComponent<MyPageAttrs> {... view() {...} }
+ *
+ * onActivate(app: App) {
+ *   pages.register(... bindMithrilApps(MyPage, {app: app});
+ * }
+ *
+ * The return value of bindMithrilApps is a mithril component that takes in
+ * input only a {subpage: string} and passes down to MyPage the combination
+ * of pre-bound and runtime attrs, that is {subpage, app}.
+ */
+export function bindMithrilAttrs<BaseAttrs, Attrs>(
+  component: m.ComponentTypes<Attrs>,
+  boundArgs: Omit<Attrs, keyof BaseAttrs>,
+): m.Component<BaseAttrs> {
+  return {
+    view(vnode: m.Vnode<BaseAttrs>) {
+      const attrs = {...vnode.attrs, ...boundArgs} as Attrs;
+      const emptyAttrs: m.CommonAttributes<Attrs, {}> = {}; // Keep tsc happy.
+      return m<Attrs, {}>(component, {...attrs, ...emptyAttrs});
+    },
+  };
+}
diff --git a/ui/src/chrome_extension/chrome_tracing_controller.ts b/ui/src/chrome_extension/chrome_tracing_controller.ts
index 916fca9..de15873 100644
--- a/ui/src/chrome_extension/chrome_tracing_controller.ts
+++ b/ui/src/chrome_extension/chrome_tracing_controller.ts
@@ -21,13 +21,13 @@
   ConsumerPortResponse,
   GetTraceStatsResponse,
   ReadBuffersResponse,
-} from '../controller/consumer_port_types';
-import {RpcConsumerPort} from '../controller/record_controller_interfaces';
+} from '../plugins/dev.perfetto.RecordTrace/consumer_port_types';
+import {RpcConsumerPort} from '../plugins/dev.perfetto.RecordTrace/record_controller_interfaces';
 import {
   browserSupportsPerfettoConfig,
   extractTraceConfig,
   hasSystemDataSourceConfig,
-} from '../core/trace_config_utils';
+} from '../plugins/dev.perfetto.RecordTrace/trace_config_utils';
 import {ITraceStats, TraceConfig} from '../protos';
 
 import {DevToolsSocket} from './devtools_socket';
diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts
deleted file mode 100644
index cc10366..0000000
--- a/ui/src/common/constants.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-export const TRACE_SUFFIX = '.perfetto-trace';
diff --git a/ui/src/common/recordingV2/recording_error_handling.ts b/ui/src/common/recordingV2/recording_error_handling.ts
deleted file mode 100644
index ffec467..0000000
--- a/ui/src/common/recordingV2/recording_error_handling.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {getErrorMessage} from '../../base/errors';
-import {
-  showAllowUSBDebugging,
-  showConnectionLostError,
-  showExtensionNotInstalled,
-  showFailedToPushBinary,
-  showIssueParsingTheTracedResponse,
-  showNoDeviceSelected,
-  showWebsocketConnectionIssue,
-  showWebUSBErrorV2,
-} from '../../frontend/error_dialog';
-import {OnMessageCallback} from './recording_interfaces_v2';
-import {
-  ALLOW_USB_DEBUGGING,
-  BINARY_PUSH_FAILURE,
-  BINARY_PUSH_UNKNOWN_RESPONSE,
-  EXTENSION_NOT_INSTALLED,
-  NO_DEVICE_SELECTED,
-  PARSING_UNABLE_TO_DECODE_METHOD,
-  PARSING_UNKNWON_REQUEST_ID,
-  PARSING_UNRECOGNIZED_MESSAGE,
-  PARSING_UNRECOGNIZED_PORT,
-  WEBSOCKET_UNABLE_TO_CONNECT,
-} from './recording_utils';
-
-// The pattern for handling recording error can have the following nesting in
-// case of errors:
-// A. wrapRecordingError -> wraps a promise
-// B. onFailure -> has user defined logic and calls showRecordingModal
-// C. showRecordingModal -> shows UX for a given error; this is not called
-//    directly by wrapRecordingError, because we want the caller (such as the
-//    UI) to dictate the UX
-
-// This method takes a promise and a callback to be execute in case the promise
-// fails. It then awaits the promise and executes the callback in case of
-// failure. In the recording code it is used to wrap:
-// 1. Acessing the WebUSB API.
-// 2. Methods returning promises which can be rejected. For instance:
-// a) When the user clicks 'Add a new device' but then doesn't select a valid
-//    device.
-// b) When the user starts a tracing session, but cancels it before they
-//    authorize the session on the device.
-export async function wrapRecordingError<T>(
-  promise: Promise<T>,
-  onFailure: OnMessageCallback,
-): Promise<T | undefined> {
-  try {
-    return await promise;
-  } catch (e) {
-    // Sometimes the message is wrapped in an Error object, sometimes not, so
-    // we make sure we transform it into a string.
-    const errorMessage = getErrorMessage(e);
-    onFailure(errorMessage);
-    return undefined;
-  }
-}
-
-// Shows a modal for every known type of error which can arise during recording.
-// In this way, errors occuring at different levels of the recording process
-// can be handled in a central location.
-export function showRecordingModal(message: string): void {
-  if (
-    [
-      'Unable to claim interface.',
-      'The specified endpoint is not part of a claimed and selected ' +
-        'alternate interface.',
-      // thrown when calling the 'reset' method on a WebUSB device.
-      'Unable to reset the device.',
-    ].some((partOfMessage) => message.includes(partOfMessage))
-  ) {
-    showWebUSBErrorV2();
-  } else if (
-    [
-      'A transfer error has occurred.',
-      'The device was disconnected.',
-      'The transfer was cancelled.',
-    ].some((partOfMessage) => message.includes(partOfMessage)) ||
-    isDeviceDisconnectedError(message)
-  ) {
-    showConnectionLostError();
-  } else if (message === ALLOW_USB_DEBUGGING) {
-    showAllowUSBDebugging();
-  } else if (
-    isMessageComposedOf(message, [
-      BINARY_PUSH_FAILURE,
-      BINARY_PUSH_UNKNOWN_RESPONSE,
-    ])
-  ) {
-    showFailedToPushBinary(message.substring(message.indexOf(':') + 1));
-  } else if (message === NO_DEVICE_SELECTED) {
-    showNoDeviceSelected();
-  } else if (WEBSOCKET_UNABLE_TO_CONNECT === message) {
-    showWebsocketConnectionIssue(message);
-  } else if (message === EXTENSION_NOT_INSTALLED) {
-    showExtensionNotInstalled();
-  } else if (
-    isMessageComposedOf(message, [
-      PARSING_UNKNWON_REQUEST_ID,
-      PARSING_UNABLE_TO_DECODE_METHOD,
-      PARSING_UNRECOGNIZED_PORT,
-      PARSING_UNRECOGNIZED_MESSAGE,
-    ])
-  ) {
-    showIssueParsingTheTracedResponse(message);
-  } else {
-    throw new Error(`${message}`);
-  }
-}
-
-function isDeviceDisconnectedError(message: string) {
-  return (
-    message.includes('Device with serial') &&
-    message.includes('was disconnected.')
-  );
-}
-
-function isMessageComposedOf(message: string, issues: string[]) {
-  for (const issue of issues) {
-    if (message.includes(issue)) {
-      return true;
-    }
-  }
-  return false;
-}
-
-// Exception thrown by the Recording logic.
-export class RecordingError extends Error {}
diff --git a/ui/src/common/track_helper.ts b/ui/src/common/track_helper.ts
index 3087228..e9ef6fb 100644
--- a/ui/src/common/track_helper.ts
+++ b/ui/src/common/track_helper.ts
@@ -95,6 +95,6 @@
     const {start, end} = this.latestTimespan;
     const resolution = this.latestResolution;
     this.data_ = await this.doFetch(start, end, resolution);
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 }
diff --git a/ui/src/core/app_impl.ts b/ui/src/core/app_impl.ts
index 24eceaf..7662755 100644
--- a/ui/src/core/app_impl.ts
+++ b/ui/src/core/app_impl.ts
@@ -32,7 +32,7 @@
 import {createProxy, getOrCreate} from '../base/utils';
 import {PageManagerImpl} from './page_manager';
 import {PageHandler} from '../public/page';
-import {setPerfHooks} from './perf';
+import {PerfManager} from './perf_manager';
 import {ServiceWorkerController} from '../frontend/service_worker_controller';
 import {FeatureFlagManager, FlagSettings} from '../public/feature_flag';
 import {featureFlags} from './feature_flags';
@@ -59,6 +59,7 @@
   readonly pageMgr = new PageManagerImpl();
   readonly sidebarMgr: SidebarManagerImpl;
   readonly pluginMgr: PluginManagerImpl;
+  readonly perfMgr = new PerfManager();
   readonly analytics: AnalyticsInternal;
   readonly serviceWorkerController: ServiceWorkerController;
   httpRpc = {
@@ -67,7 +68,6 @@
   };
   initialRouteArgs: RouteArgs;
   isLoadingTrace = false; // Set when calling openTrace().
-  perfDebugging = false; // Enables performance debugging of tracks/panels.
   readonly initArgs: AppInitArgs;
   readonly embeddedMode: boolean;
   readonly testingMode: boolean;
@@ -79,19 +79,31 @@
   // The currently open trace.
   currentTrace?: TraceContext;
 
+  private static _instance: AppContext;
+
+  static initialize(initArgs: AppInitArgs): AppContext {
+    assertTrue(AppContext._instance === undefined);
+    return (AppContext._instance = new AppContext(initArgs));
+  }
+
+  static get instance(): AppContext {
+    return assertExists(AppContext._instance);
+  }
+
   // This constructor is invoked only once, when frontend/index.ts invokes
   // AppMainImpl.initialize().
-  constructor(initArgs: AppInitArgs) {
+  private constructor(initArgs: AppInitArgs) {
     this.initArgs = initArgs;
     this.initialRouteArgs = initArgs.initialRouteArgs;
-    this.sidebarMgr = new SidebarManagerImpl({
-      sidebarEnabled: !this.initialRouteArgs.hideSidebar,
-    });
     this.serviceWorkerController = new ServiceWorkerController();
     this.embeddedMode = this.initialRouteArgs.mode === 'embedded';
     this.testingMode =
       self.location !== undefined &&
       self.location.search.indexOf('testing=1') >= 0;
+    this.sidebarMgr = new SidebarManagerImpl({
+      disabled: this.embeddedMode,
+      hidden: this.initialRouteArgs.hideSidebar,
+    });
     this.analytics = initAnalytics(this.testingMode, this.embeddedMode);
     this.pluginMgr = new PluginManagerImpl({
       forkForPlugin: (pluginId) => this.forPlugin(pluginId),
@@ -143,19 +155,16 @@
   private readonly appCtx: AppContext;
   private readonly pageMgrProxy: PageManagerImpl;
 
+  // Invoked by frontend/index.ts.
+  static initialize(args: AppInitArgs) {
+    AppContext.initialize(args).forPlugin(CORE_PLUGIN_ID);
+  }
+
   // Gets access to the one instance that the core can use. Note that this is
   // NOT the only instance, as other AppImpl instance will be created for each
   // plugin.
-  private static _instance: AppImpl;
-
-  // Invoked by frontend/index.ts.
-  static initialize(args: AppInitArgs) {
-    assertTrue(AppImpl._instance === undefined);
-    AppImpl._instance = new AppContext(args).forPlugin(CORE_PLUGIN_ID);
-  }
-
   static get instance(): AppImpl {
-    return assertExists(AppImpl._instance);
+    return AppContext.instance.forPlugin(CORE_PLUGIN_ID);
   }
 
   // Only called by AppContext.forPlugin().
@@ -173,6 +182,10 @@
     });
   }
 
+  forPlugin(pluginId: string): AppImpl {
+    return this.appCtx.forPlugin(pluginId);
+  }
+
   get commands(): CommandManagerImpl {
     return this.appCtx.commandMgr;
   }
@@ -236,7 +249,6 @@
   }
 
   private async openTrace(src: TraceSource) {
-    assertTrue(this.pluginId === CORE_PLUGIN_ID);
     this.appCtx.closeCurrentTrace();
     this.appCtx.isLoadingTrace = true;
     try {
@@ -284,17 +296,8 @@
     return this.appCtx.extraSqlPackages;
   }
 
-  get perfDebugging(): boolean {
-    return this.appCtx.perfDebugging;
-  }
-
-  setPerfDebuggingEnabled(enabled: boolean) {
-    this.appCtx.perfDebugging = enabled;
-    setPerfHooks(
-      () => this.perfDebugging,
-      () => this.setPerfDebuggingEnabled(!this.perfDebugging),
-    );
-    raf.scheduleFullRedraw();
+  get perfDebugging(): PerfManager {
+    return this.appCtx.perfMgr;
   }
 
   get serviceWorkerController(): ServiceWorkerController {
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts
index d0ee370..4791e8e 100644
--- a/ui/src/core/default_plugins.ts
+++ b/ui/src/core/default_plugins.ts
@@ -56,6 +56,7 @@
   'dev.perfetto.ProcessSummary',
   'dev.perfetto.ProcessThreadGroups',
   'dev.perfetto.QueryPage',
+  'dev.perfetto.RecordTrace',
   'dev.perfetto.RestorePinnedTrack',
   'dev.perfetto.Sched',
   'dev.perfetto.Screenshots',
diff --git a/ui/src/core/feature_flags.ts b/ui/src/core/feature_flags.ts
index 68ce511..4e60a61 100644
--- a/ui/src/core/feature_flags.ts
+++ b/ui/src/core/feature_flags.ts
@@ -202,10 +202,3 @@
 
 export const FlagsForTesting = Flags;
 export const featureFlags = new Flags(new LocalStorageStore());
-
-export const RECORDING_V2_FLAG = featureFlags.register({
-  id: 'recordingv2',
-  name: 'Recording V2',
-  description: 'Record using V2 interface',
-  defaultValue: false,
-});
diff --git a/ui/src/core/perf.ts b/ui/src/core/perf.ts
deleted file mode 100644
index 6e9afaf..0000000
--- a/ui/src/core/perf.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import m from 'mithril';
-
-const hooks = {
-  isDebug: () => false,
-  toggleDebug: () => {},
-};
-
-export function setPerfHooks(isDebug: () => boolean, toggleDebug: () => void) {
-  hooks.isDebug = isDebug;
-  hooks.toggleDebug = toggleDebug;
-}
-
-// Shorthand for if globals perf debug mode is on.
-export const perfDebug = () => hooks.isDebug();
-
-// Returns performance.now() if perfDebug is enabled, otherwise 0.
-// This is needed because calling performance.now is generally expensive
-// and should not be done for every frame.
-export const debugNow = () => (perfDebug() ? performance.now() : 0);
-
-// Returns execution time of |fn| if perf debug mode is on. Returns 0 otherwise.
-export function measure(fn: () => void): number {
-  const start = debugNow();
-  fn();
-  return debugNow() - start;
-}
-
-// Stores statistics about samples, and keeps a fixed size buffer of most recent
-// samples.
-export class RunningStatistics {
-  private _count = 0;
-  private _mean = 0;
-  private _lastValue = 0;
-  private _ptr = 0;
-
-  private buffer: number[] = [];
-
-  constructor(private _maxBufferSize = 10) {}
-
-  addValue(value: number) {
-    this._lastValue = value;
-    if (this.buffer.length >= this._maxBufferSize) {
-      this.buffer[this._ptr++] = value;
-      if (this._ptr >= this.buffer.length) {
-        this._ptr -= this.buffer.length;
-      }
-    } else {
-      this.buffer.push(value);
-    }
-
-    this._mean = (this._mean * this._count + value) / (this._count + 1);
-    this._count++;
-  }
-
-  get mean() {
-    return this._mean;
-  }
-  get count() {
-    return this._count;
-  }
-  get bufferMean() {
-    return this.buffer.reduce((sum, v) => sum + v, 0) / this.buffer.length;
-  }
-  get bufferSize() {
-    return this.buffer.length;
-  }
-  get maxBufferSize() {
-    return this._maxBufferSize;
-  }
-  get last() {
-    return this._lastValue;
-  }
-}
-
-// Returns a summary string representation of a RunningStatistics object.
-export function runningStatStr(stat: RunningStatistics) {
-  return (
-    `Last: ${stat.last.toFixed(2)}ms | ` +
-    `Avg: ${stat.mean.toFixed(2)}ms | ` +
-    `Avg${stat.maxBufferSize}: ${stat.bufferMean.toFixed(2)}ms`
-  );
-}
-
-export interface PerfStatsSource {
-  renderPerfStats(): m.Children;
-}
-
-// Globals singleton class that renders performance stats for the whole app.
-class PerfDisplay {
-  private containers: PerfStatsSource[] = [];
-
-  addContainer(container: PerfStatsSource) {
-    this.containers.push(container);
-  }
-
-  removeContainer(container: PerfStatsSource) {
-    const i = this.containers.indexOf(container);
-    this.containers.splice(i, 1);
-  }
-
-  renderPerfStats(src: PerfStatsSource) {
-    if (!perfDebug()) return;
-    const perfDisplayEl = document.querySelector('.perf-stats');
-    if (!perfDisplayEl) return;
-    m.render(perfDisplayEl, [
-      m('section', src.renderPerfStats()),
-      m(
-        'button.close-button',
-        {
-          onclick: hooks.toggleDebug,
-        },
-        m('i.material-icons', 'close'),
-      ),
-      this.containers.map((c, i) =>
-        m('section', m('div', `Panel Container ${i + 1}`), c.renderPerfStats()),
-      ),
-    ]);
-  }
-}
-
-export const perfDisplay = new PerfDisplay();
diff --git a/ui/src/core/perf_manager.ts b/ui/src/core/perf_manager.ts
new file mode 100644
index 0000000..e63e7e8
--- /dev/null
+++ b/ui/src/core/perf_manager.ts
@@ -0,0 +1,145 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {raf} from './raf_scheduler';
+import {PerfStats, PerfStatsContainer, runningStatStr} from './perf_stats';
+
+export class PerfManager {
+  private _enabled = false;
+  readonly containers: PerfStatsContainer[] = [];
+
+  get enabled(): boolean {
+    return this._enabled;
+  }
+
+  set enabled(enabled: boolean) {
+    this._enabled = enabled;
+    raf.setPerfStatsEnabled(true);
+    this.containers.forEach((c) => c.setPerfStatsEnabled(enabled));
+  }
+
+  addContainer(container: PerfStatsContainer): Disposable {
+    this.containers.push(container);
+    return {
+      [Symbol.dispose]: () => {
+        const i = this.containers.indexOf(container);
+        this.containers.splice(i, 1);
+      },
+    };
+  }
+
+  renderPerfStats(): m.Children {
+    if (!this._enabled) return;
+    // The rendering of the perf stats UI is atypical. The main issue is that we
+    // want to redraw the mithril component even if there is no full DOM redraw
+    // happening (and we don't want to force redraws as a side effect). So we
+    // return here just a container and handle its rendering ourselves.
+    const perfMgr = this;
+    let removed = false;
+    return m('.perf-stats', {
+      oncreate(vnode: m.VnodeDOM) {
+        const animationFrame = (dom: Element) => {
+          if (removed) return;
+          m.render(dom, m(PerfStatsUi, {perfMgr}));
+          requestAnimationFrame(() => animationFrame(dom));
+        };
+        animationFrame(vnode.dom);
+      },
+      onremove() {
+        removed = true;
+      },
+    });
+  }
+}
+
+// The mithril component that draws the contents of the perf stats box.
+
+interface PerfStatsUiAttrs {
+  perfMgr: PerfManager;
+}
+
+class PerfStatsUi implements m.ClassComponent<PerfStatsUiAttrs> {
+  view({attrs}: m.Vnode<PerfStatsUiAttrs>) {
+    return m(
+      '.perf-stats',
+      {},
+      m('section', this.renderRafSchedulerStats()),
+      m(
+        'button.close-button',
+        {
+          onclick: () => (attrs.perfMgr.enabled = false),
+        },
+        m('i.material-icons', 'close'),
+      ),
+      attrs.perfMgr.containers.map((c, i) =>
+        m('section', m('div', `Panel Container ${i + 1}`), c.renderPerfStats()),
+      ),
+    );
+  }
+
+  renderRafSchedulerStats() {
+    return m(
+      'div',
+      m('div', [
+        m(
+          'button',
+          {onclick: () => raf.scheduleCanvasRedraw()},
+          'Do Canvas Redraw',
+        ),
+        '   |   ',
+        m(
+          'button',
+          {onclick: () => raf.scheduleFullRedraw()},
+          'Do Full Redraw',
+        ),
+      ]),
+      m('div', 'Raf Timing ' + '(Total may not add up due to imprecision)'),
+      m(
+        'table',
+        this.statTableHeader(),
+        this.statTableRow('Actions', raf.perfStats.rafActions),
+        this.statTableRow('Dom', raf.perfStats.rafDom),
+        this.statTableRow('Canvas', raf.perfStats.rafCanvas),
+        this.statTableRow('Total', raf.perfStats.rafTotal),
+      ),
+      m(
+        'div',
+        'Dom redraw: ' +
+          `Count: ${raf.perfStats.domRedraw.count} | ` +
+          runningStatStr(raf.perfStats.domRedraw),
+      ),
+    );
+  }
+
+  statTableHeader() {
+    return m(
+      'tr',
+      m('th', ''),
+      m('th', 'Last (ms)'),
+      m('th', 'Avg (ms)'),
+      m('th', 'Avg-10 (ms)'),
+    );
+  }
+
+  statTableRow(title: string, stat: PerfStats) {
+    return m(
+      'tr',
+      m('td', title),
+      m('td', stat.last.toFixed(2)),
+      m('td', stat.mean.toFixed(2)),
+      m('td', stat.bufferMean.toFixed(2)),
+    );
+  }
+}
diff --git a/ui/src/core/perf_stats.ts b/ui/src/core/perf_stats.ts
new file mode 100644
index 0000000..3f1eda0
--- /dev/null
+++ b/ui/src/core/perf_stats.ts
@@ -0,0 +1,78 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+// The interface that every container (e.g. Track Panels) that exposes granular
+// per-container masurements implements to be perf-stats-aware.
+export interface PerfStatsContainer {
+  setPerfStatsEnabled(enable: boolean): void;
+  renderPerfStats(): m.Children;
+}
+
+// Stores statistics about samples, and keeps a fixed size buffer of most recent
+// samples.
+export class PerfStats {
+  private _count = 0;
+  private _mean = 0;
+  private _lastValue = 0;
+  private _ptr = 0;
+
+  private buffer: number[] = [];
+
+  constructor(private _maxBufferSize = 10) {}
+
+  addValue(value: number) {
+    this._lastValue = value;
+    if (this.buffer.length >= this._maxBufferSize) {
+      this.buffer[this._ptr++] = value;
+      if (this._ptr >= this.buffer.length) {
+        this._ptr -= this.buffer.length;
+      }
+    } else {
+      this.buffer.push(value);
+    }
+
+    this._mean = (this._mean * this._count + value) / (this._count + 1);
+    this._count++;
+  }
+
+  get mean() {
+    return this._mean;
+  }
+  get count() {
+    return this._count;
+  }
+  get bufferMean() {
+    return this.buffer.reduce((sum, v) => sum + v, 0) / this.buffer.length;
+  }
+  get bufferSize() {
+    return this.buffer.length;
+  }
+  get maxBufferSize() {
+    return this._maxBufferSize;
+  }
+  get last() {
+    return this._lastValue;
+  }
+}
+
+// Returns a summary string representation of a RunningStatistics object.
+export function runningStatStr(stat: PerfStats) {
+  return (
+    `Last: ${stat.last.toFixed(2)}ms | ` +
+    `Avg: ${stat.mean.toFixed(2)}ms | ` +
+    `Avg${stat.maxBufferSize}: ${stat.bufferMean.toFixed(2)}ms`
+  );
+}
diff --git a/ui/src/core/perf_unittest.ts b/ui/src/core/perf_stats_unittest.ts
similarity index 86%
rename from ui/src/core/perf_unittest.ts
rename to ui/src/core/perf_stats_unittest.ts
index 5ba357c..1b24bf5 100644
--- a/ui/src/core/perf_unittest.ts
+++ b/ui/src/core/perf_stats_unittest.ts
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {RunningStatistics} from './perf';
+import {PerfStats} from './perf_stats';
 
 test('buffer size is accurate before reaching max capacity', () => {
-  const buf = new RunningStatistics(10);
+  const buf = new PerfStats(10);
 
   for (let i = 0; i < 10; i++) {
     buf.addValue(i);
@@ -24,7 +24,7 @@
 });
 
 test('buffer size is accurate after reaching max capacity', () => {
-  const buf = new RunningStatistics(10);
+  const buf = new PerfStats(10);
 
   for (let i = 0; i < 10; i++) {
     buf.addValue(i);
@@ -37,7 +37,7 @@
 });
 
 test('buffer mean is accurate before reaching max capacity', () => {
-  const buf = new RunningStatistics(10);
+  const buf = new PerfStats(10);
 
   buf.addValue(1);
   buf.addValue(2);
@@ -47,7 +47,7 @@
 });
 
 test('buffer mean is accurate after reaching max capacity', () => {
-  const buf = new RunningStatistics(10);
+  const buf = new PerfStats(10);
 
   for (let i = 0; i < 20; i++) {
     buf.addValue(2);
diff --git a/ui/src/core/raf_scheduler.ts b/ui/src/core/raf_scheduler.ts
index c6ca0fc..b23379f 100644
--- a/ui/src/core/raf_scheduler.ts
+++ b/ui/src/core/raf_scheduler.ts
@@ -12,39 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import m from 'mithril';
-import {
-  debugNow,
-  measure,
-  perfDebug,
-  perfDisplay,
-  PerfStatsSource,
-  RunningStatistics,
-  runningStatStr,
-} from './perf';
+import {PerfStats} from './perf_stats';
 
-function statTableHeader() {
-  return m(
-    'tr',
-    m('th', ''),
-    m('th', 'Last (ms)'),
-    m('th', 'Avg (ms)'),
-    m('th', 'Avg-10 (ms)'),
-  );
-}
-
-function statTableRow(title: string, stat: RunningStatistics) {
-  return m(
-    'tr',
-    m('td', title),
-    m('td', stat.last.toFixed(2)),
-    m('td', stat.mean.toFixed(2)),
-    m('td', stat.bufferMean.toFixed(2)),
-  );
-}
-
-export type ActionCallback = (nowMs: number) => void;
-export type RedrawCallback = (nowMs: number) => void;
+export type AnimationCallback = (lastFrameMs: number) => void;
+export type RedrawCallback = () => void;
 
 // This class orchestrates all RAFs in the UI. It ensures that there is only
 // one animation frame handler overall and that callbacks are called in
@@ -54,146 +25,134 @@
 // - redraw callbacks that will repaint canvases.
 // This class guarantees that, on each frame, redraw callbacks are called after
 // all action callbacks.
-export class RafScheduler implements PerfStatsSource {
-  private actionCallbacks = new Set<ActionCallback>();
+export class RafScheduler {
+  // These happen at the beginning of any animation frame. Used by Animation.
+  private animationCallbacks = new Set<AnimationCallback>();
+
+  // These happen during any animaton frame, after the (optional) DOM redraw.
   private canvasRedrawCallbacks = new Set<RedrawCallback>();
-  private _syncDomRedraw: RedrawCallback = (_) => {};
+
+  // These happen at the end of full (DOM) animation frames.
+  private postRedrawCallbacks = new Array<RedrawCallback>();
+  private syncDomRedrawFn: () => void = () => {};
   private hasScheduledNextFrame = false;
   private requestedFullRedraw = false;
   private isRedrawing = false;
   private _shutdown = false;
-  private _beforeRedraw: () => void = () => {};
-  private _afterRedraw: () => void = () => {};
-  private _pendingCallbacks: RedrawCallback[] = [];
+  private recordPerfStats = false;
 
-  private perfStats = {
-    rafActions: new RunningStatistics(),
-    rafCanvas: new RunningStatistics(),
-    rafDom: new RunningStatistics(),
-    rafTotal: new RunningStatistics(),
-    domRedraw: new RunningStatistics(),
+  readonly perfStats = {
+    rafActions: new PerfStats(),
+    rafCanvas: new PerfStats(),
+    rafDom: new PerfStats(),
+    rafTotal: new PerfStats(),
+    domRedraw: new PerfStats(),
   };
 
-  start(cb: ActionCallback) {
-    this.actionCallbacks.add(cb);
-    this.maybeScheduleAnimationFrame();
+  // Called by frontend/index.ts. syncDomRedrawFn is a function that invokes
+  // m.render() of the root UiMain component.
+  initialize(syncDomRedrawFn: () => void) {
+    this.syncDomRedrawFn = syncDomRedrawFn;
   }
 
-  stop(cb: ActionCallback) {
-    this.actionCallbacks.delete(cb);
-  }
-
-  addRedrawCallback(cb: RedrawCallback) {
-    this.canvasRedrawCallbacks.add(cb);
-  }
-
-  removeRedrawCallback(cb: RedrawCallback) {
-    this.canvasRedrawCallbacks.delete(cb);
-  }
-
-  addPendingCallback(cb: RedrawCallback) {
-    this._pendingCallbacks.push(cb);
+  // Schedule re-rendering of virtual DOM and canvas.
+  // If a callback is passed it will be executed after the DOM redraw has
+  // completed.
+  scheduleFullRedraw(cb?: RedrawCallback) {
+    this.requestedFullRedraw = true;
+    cb && this.postRedrawCallbacks.push(cb);
+    this.maybeScheduleAnimationFrame(true);
   }
 
   // Schedule re-rendering of canvas only.
-  scheduleRedraw() {
+  scheduleCanvasRedraw() {
     this.maybeScheduleAnimationFrame(true);
   }
 
+  startAnimation(cb: AnimationCallback) {
+    this.animationCallbacks.add(cb);
+    this.maybeScheduleAnimationFrame();
+  }
+
+  stopAnimation(cb: AnimationCallback) {
+    this.animationCallbacks.delete(cb);
+  }
+
+  addCanvasRedrawCallback(cb: RedrawCallback): Disposable {
+    this.canvasRedrawCallbacks.add(cb);
+    const canvasRedrawCallbacks = this.canvasRedrawCallbacks;
+    return {
+      [Symbol.dispose]() {
+        canvasRedrawCallbacks.delete(cb);
+      },
+    };
+  }
+
   shutdown() {
     this._shutdown = true;
   }
 
-  set domRedraw(cb: RedrawCallback) {
-    this._syncDomRedraw = cb;
-  }
-
-  set beforeRedraw(cb: () => void) {
-    this._beforeRedraw = cb;
-  }
-
-  set afterRedraw(cb: () => void) {
-    this._afterRedraw = cb;
-  }
-
-  // Schedule re-rendering of virtual DOM and canvas.
-  scheduleFullRedraw() {
-    this.requestedFullRedraw = true;
-    this.maybeScheduleAnimationFrame(true);
-  }
-
-  // Schedule a full redraw to happen after a short delay (50 ms).
-  // This is done to prevent flickering / visual noise and allow the UI to fetch
-  // the initial data from the Trace Processor.
-  // There is a chance that someone else schedules a full redraw in the
-  // meantime, forcing the flicker, but in practice it works quite well and
-  // avoids a lot of complexity for the callers.
-  scheduleDelayedFullRedraw() {
-    // 50ms is half of the responsiveness threshold (100ms):
-    // https://web.dev/rail/#response-process-events-in-under-50ms
-    const delayMs = 50;
-    setTimeout(() => this.scheduleFullRedraw(), delayMs);
-  }
-
-  syncDomRedraw(nowMs: number) {
-    const redrawStart = debugNow();
-    this._syncDomRedraw(nowMs);
-    if (perfDebug()) {
-      this.perfStats.domRedraw.addValue(debugNow() - redrawStart);
-    }
+  setPerfStatsEnabled(enabled: boolean) {
+    this.recordPerfStats = enabled;
+    this.scheduleFullRedraw();
   }
 
   get hasPendingRedraws(): boolean {
     return this.isRedrawing || this.hasScheduledNextFrame;
   }
 
-  private syncCanvasRedraw(nowMs: number) {
-    const redrawStart = debugNow();
-    if (this.isRedrawing) return;
-    this._beforeRedraw();
-    this.isRedrawing = true;
-    for (const redraw of this.canvasRedrawCallbacks) redraw(nowMs);
-    this.isRedrawing = false;
-    this._afterRedraw();
-    for (const cb of this._pendingCallbacks) {
-      cb(nowMs);
+  private syncDomRedraw() {
+    const redrawStart = performance.now();
+    this.syncDomRedrawFn();
+    if (this.recordPerfStats) {
+      this.perfStats.domRedraw.addValue(performance.now() - redrawStart);
     }
-    this._pendingCallbacks.splice(0, this._pendingCallbacks.length);
-    if (perfDebug()) {
-      this.perfStats.rafCanvas.addValue(debugNow() - redrawStart);
+  }
+
+  private syncCanvasRedraw() {
+    const redrawStart = performance.now();
+    if (this.isRedrawing) return;
+    this.isRedrawing = true;
+    this.canvasRedrawCallbacks.forEach((cb) => cb());
+    this.isRedrawing = false;
+    if (this.recordPerfStats) {
+      this.perfStats.rafCanvas.addValue(performance.now() - redrawStart);
     }
   }
 
   private maybeScheduleAnimationFrame(force = false) {
     if (this.hasScheduledNextFrame) return;
-    if (this.actionCallbacks.size !== 0 || force) {
+    if (this.animationCallbacks.size !== 0 || force) {
       this.hasScheduledNextFrame = true;
       window.requestAnimationFrame(this.onAnimationFrame.bind(this));
     }
   }
 
-  private onAnimationFrame(nowMs: number) {
+  private onAnimationFrame(lastFrameMs: number) {
     if (this._shutdown) return;
-    const rafStart = debugNow();
     this.hasScheduledNextFrame = false;
-
     const doFullRedraw = this.requestedFullRedraw;
     this.requestedFullRedraw = false;
 
-    const actionTime = measure(() => {
-      for (const action of this.actionCallbacks) action(nowMs);
-    });
+    const tStart = performance.now();
+    this.animationCallbacks.forEach((cb) => cb(lastFrameMs));
+    const tAnim = performance.now();
+    doFullRedraw && this.syncDomRedraw();
+    const tDom = performance.now();
+    this.syncCanvasRedraw();
+    const tCanvas = performance.now();
 
-    const domTime = measure(() => {
-      if (doFullRedraw) this.syncDomRedraw(nowMs);
-    });
-    const canvasTime = measure(() => this.syncCanvasRedraw(nowMs));
-
-    const totalRafTime = debugNow() - rafStart;
-    this.updatePerfStats(actionTime, domTime, canvasTime, totalRafTime);
-    perfDisplay.renderPerfStats(this);
-
+    const animTime = tAnim - tStart;
+    const domTime = tDom - tAnim;
+    const canvasTime = tCanvas - tDom;
+    const totalTime = tCanvas - tStart;
+    this.updatePerfStats(animTime, domTime, canvasTime, totalTime);
     this.maybeScheduleAnimationFrame();
+
+    if (doFullRedraw && this.postRedrawCallbacks.length > 0) {
+      const pendingCbs = this.postRedrawCallbacks.splice(0); // splice = clear.
+      pendingCbs.forEach((cb) => cb());
+    }
   }
 
   private updatePerfStats(
@@ -202,42 +161,12 @@
     canvasTime: number,
     totalRafTime: number,
   ) {
-    if (!perfDebug()) return;
+    if (!this.recordPerfStats) return;
     this.perfStats.rafActions.addValue(actionsTime);
     this.perfStats.rafDom.addValue(domTime);
     this.perfStats.rafCanvas.addValue(canvasTime);
     this.perfStats.rafTotal.addValue(totalRafTime);
   }
-
-  renderPerfStats() {
-    return m(
-      'div',
-      m('div', [
-        m('button', {onclick: () => this.scheduleRedraw()}, 'Do Canvas Redraw'),
-        '   |   ',
-        m(
-          'button',
-          {onclick: () => this.scheduleFullRedraw()},
-          'Do Full Redraw',
-        ),
-      ]),
-      m('div', 'Raf Timing ' + '(Total may not add up due to imprecision)'),
-      m(
-        'table',
-        statTableHeader(),
-        statTableRow('Actions', this.perfStats.rafActions),
-        statTableRow('Dom', this.perfStats.rafDom),
-        statTableRow('Canvas', this.perfStats.rafCanvas),
-        statTableRow('Total', this.perfStats.rafTotal),
-      ),
-      m(
-        'div',
-        'Dom redraw: ' +
-          `Count: ${this.perfStats.domRedraw.count} | ` +
-          runningStatStr(this.perfStats.domRedraw),
-      ),
-    );
-  }
 }
 
 export const raf = new RafScheduler();
diff --git a/ui/src/core/scroll_helper.ts b/ui/src/core/scroll_helper.ts
index 59b7b11..c732b91 100644
--- a/ui/src/core/scroll_helper.ts
+++ b/ui/src/core/scroll_helper.ts
@@ -35,7 +35,7 @@
   // See comments in ScrollToArgs for the intended semantics.
   scrollTo(args: ScrollToArgs) {
     const {time, track} = args;
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
 
     if (time !== undefined) {
       if (time.end === undefined) {
diff --git a/ui/src/core/sidebar_manager.ts b/ui/src/core/sidebar_manager.ts
index 11c12dd..9de9b90 100644
--- a/ui/src/core/sidebar_manager.ts
+++ b/ui/src/core/sidebar_manager.ts
@@ -27,9 +27,9 @@
 
   readonly menuItems = new Registry<SidebarMenuItemInternal>((m) => m.id);
 
-  constructor(args: {sidebarEnabled: boolean}) {
-    this.enabled = args.sidebarEnabled;
-    this._visible = args.sidebarEnabled;
+  constructor(args: {disabled?: boolean; hidden?: boolean}) {
+    this.enabled = !args.disabled;
+    this._visible = !args.hidden;
   }
 
   addMenuItem(item: SidebarMenuItem): Disposable {
diff --git a/ui/src/core/timeline.ts b/ui/src/core/timeline.ts
index d91503c..bc8a613 100644
--- a/ui/src/core/timeline.ts
+++ b/ui/src/core/timeline.ts
@@ -46,7 +46,7 @@
 
   set highlightedSliceId(x) {
     this._highlightedSliceId = x;
-    raf.scheduleFullRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   get hoveredNoteTimestamp() {
@@ -55,7 +55,7 @@
 
   set hoveredNoteTimestamp(x) {
     this._hoveredNoteTimestamp = x;
-    raf.scheduleFullRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   get hoveredUtid() {
@@ -64,7 +64,7 @@
 
   set hoveredUtid(x) {
     this._hoveredUtid = x;
-    raf.scheduleFullRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   get hoveredPid() {
@@ -73,7 +73,7 @@
 
   set hoveredPid(x) {
     this._hoveredPid = x;
-    raf.scheduleFullRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   // This is used to calculate the tracks within a Y range for area selection.
@@ -95,7 +95,7 @@
       .scale(ratio, centerPoint, MIN_DURATION)
       .fitWithin(this.traceInfo.start, this.traceInfo.end);
 
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   panVisibleWindow(delta: number) {
@@ -103,7 +103,7 @@
       .translate(delta)
       .fitWithin(this.traceInfo.start, this.traceInfo.end);
 
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   // Given a timestamp, if |ts| is not currently in view move the view to
@@ -136,7 +136,7 @@
 
   deselectArea() {
     this._selectedArea = undefined;
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   get selectedArea(): Area | undefined {
@@ -160,7 +160,7 @@
       .clampDuration(MIN_DURATION)
       .fitWithin(this.traceInfo.start, this.traceInfo.end);
 
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   // Get the bounds of the visible window as a high-precision time span
@@ -174,7 +174,7 @@
 
   set hoverCursorTimestamp(t: time | undefined) {
     this._hoverCursorTimestamp = t;
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   // Offset between t=0 and the configured time domain.
diff --git a/ui/src/core/trace_impl.ts b/ui/src/core/trace_impl.ts
index b3ab662..abed7f5 100644
--- a/ui/src/core/trace_impl.ts
+++ b/ui/src/core/trace_impl.ts
@@ -48,6 +48,9 @@
 import {PageManagerImpl} from './page_manager';
 import {FeatureFlagManager, FlagSettings} from '../public/feature_flag';
 import {featureFlags} from './feature_flags';
+import {SerializedAppState} from './state_serialization_schema';
+import {PostedTrace} from './trace_source';
+import {PerfManager} from './perf_manager';
 
 /**
  * Handles the per-trace state of the UI
@@ -423,6 +426,18 @@
     this.appImpl.navigate(newHash);
   }
 
+  openTraceFromFile(file: File): void {
+    this.appImpl.openTraceFromFile(file);
+  }
+
+  openTraceFromUrl(url: string, serializedAppState?: SerializedAppState) {
+    this.appImpl.openTraceFromUrl(url, serializedAppState);
+  }
+
+  openTraceFromBuffer(args: PostedTrace): void {
+    this.appImpl.openTraceFromBuffer(args);
+  }
+
   addEventListener<T extends keyof EventListeners>(
     event: T,
     callback: EventListeners[T],
@@ -446,6 +461,10 @@
     }
   }
 
+  get perfDebugging(): PerfManager {
+    return this.appImpl.perfDebugging;
+  }
+
   get trash(): DisposableStack {
     return this.traceCtx.trash;
   }
diff --git a/ui/src/frontend/animation.ts b/ui/src/frontend/animation.ts
index c8428c4..74cf065 100644
--- a/ui/src/frontend/animation.ts
+++ b/ui/src/frontend/animation.ts
@@ -31,12 +31,12 @@
     }
     this.startMs = nowMs;
     this.endMs = nowMs + durationMs;
-    raf.start(this.boundOnAnimationFrame);
+    raf.startAnimation(this.boundOnAnimationFrame);
   }
 
   stop() {
     this.endMs = 0;
-    raf.stop(this.boundOnAnimationFrame);
+    raf.stopAnimation(this.boundOnAnimationFrame);
   }
 
   get startTimeMs(): number {
@@ -45,7 +45,7 @@
 
   private onAnimationFrame(nowMs: number) {
     if (nowMs >= this.endMs) {
-      raf.stop(this.boundOnAnimationFrame);
+      raf.stopAnimation(this.boundOnAnimationFrame);
       return;
     }
     this.onAnimationStep(Math.max(Math.round(nowMs - this.startMs), 0));
diff --git a/ui/src/frontend/base_counter_track.ts b/ui/src/frontend/base_counter_track.ts
index c09ccbc..b5d57fa 100644
--- a/ui/src/frontend/base_counter_track.ts
+++ b/ui/src/frontend/base_counter_track.ts
@@ -867,7 +867,7 @@
     this.countersKey = countersKey;
     this.counters = data;
 
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   private async createTableAndFetchLimits(
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index 7ef1cd4..0ca6c01 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -694,7 +694,7 @@
     this.onUpdatedSlices(slices);
     this.slices = slices;
 
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 
   private rowToSliceInternal(row: RowT): CastInternal<SliceT> {
diff --git a/ui/src/frontend/error_dialog.ts b/ui/src/frontend/error_dialog.ts
index affbe70..99d4157 100644
--- a/ui/src/frontend/error_dialog.ts
+++ b/ui/src/frontend/error_dialog.ts
@@ -14,9 +14,7 @@
 
 import m from 'mithril';
 import {ErrorDetails} from '../base/logging';
-import {EXTENSION_URL} from '../common/recordingV2/recording_utils';
 import {GcsUploader} from '../base/gcs_uploader';
-import {RECORDING_V2_FLAG} from '../core/feature_flags';
 import {raf} from '../core/raf_scheduler';
 import {VERSION} from '../gen/perfetto_version';
 import {getCurrentModalKey, showModal} from '../widgets/modal';
@@ -47,22 +45,20 @@
     return;
   }
 
-  if (!RECORDING_V2_FLAG.get()) {
-    if (err.message.includes('Unable to claim interface')) {
-      showWebUSBError();
-      timeLastReport = now;
-      return;
-    }
+  if (err.message.includes('Unable to claim interface')) {
+    showWebUSBError();
+    timeLastReport = now;
+    return;
+  }
 
-    if (
-      err.message.includes('A transfer error has occurred') ||
-      err.message.includes('The device was disconnected') ||
-      err.message.includes('The transfer was cancelled')
-    ) {
-      showConnectionLostError();
-      timeLastReport = now;
-      return;
-    }
+  if (
+    err.message.includes('A transfer error has occurred') ||
+    err.message.includes('The device was disconnected') ||
+    err.message.includes('The transfer was cancelled')
+  ) {
+    showConnectionLostError();
+    timeLastReport = now;
+    return;
   }
 
   if (err.message.includes('(ERR:fmt)')) {
@@ -356,135 +352,6 @@
   });
 }
 
-export function showWebUSBErrorV2() {
-  showModal({
-    title: 'A WebUSB error occurred',
-    content: m(
-      'div',
-      m(
-        'span',
-        `Is adb already running on the host? Run this command and
-      try again.`,
-      ),
-      m('br'),
-      m('.modal-bash', '> adb kill-server'),
-      m('br'),
-      // The statement below covers the following edge case:
-      // 1. 'adb server' is running on the device.
-      // 2. The user selects the new Android target, so we try to fetch the
-      // OS version and do QSS.
-      // 3. The error modal is shown.
-      // 4. The user runs 'adb kill-server'.
-      // At this point we don't have a trigger to try fetching the OS version
-      // + QSS again. Therefore, the user will need to refresh the page.
-      m(
-        'span',
-        "If after running 'adb kill-server', you don't see " +
-          "a 'Start Recording' button on the page and you don't see " +
-          "'Allow USB debugging' on the device, " +
-          'you will need to reload this page.',
-      ),
-      m('br'),
-      m('br'),
-      m('span', 'For details see '),
-      m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'),
-    ),
-  });
-}
-
-export function showConnectionLostError(): void {
-  showModal({
-    title: 'Connection with the ADB device lost',
-    content: m(
-      'div',
-      m('span', `Please connect the device again to restart the recording.`),
-      m('br'),
-    ),
-  });
-}
-
-export function showAllowUSBDebugging(): void {
-  showModal({
-    title: 'Could not connect to the device',
-    content: m(
-      'div',
-      m('span', 'Please allow USB debugging on the device.'),
-      m('br'),
-    ),
-  });
-}
-
-export function showNoDeviceSelected(): void {
-  showModal({
-    title: 'No device was selected for recording',
-    content: m(
-      'div',
-      m(
-        'span',
-        `If you want to connect to an ADB device,
-           please select it from the list.`,
-      ),
-      m('br'),
-    ),
-  });
-}
-
-export function showExtensionNotInstalled(): void {
-  showModal({
-    title: 'Perfetto Chrome extension not installed',
-    content: m(
-      'div',
-      m(
-        '.note',
-        `To trace Chrome from the Perfetto UI, you need to install our `,
-        m('a', {href: EXTENSION_URL, target: '_blank'}, 'Chrome extension'),
-        ' and then reload this page.',
-      ),
-      m('br'),
-    ),
-  });
-}
-
-export function showWebsocketConnectionIssue(message: string): void {
-  showModal({
-    title: 'Unable to connect to the device via websocket',
-    content: m(
-      'div',
-      m('div', 'trace_processor_shell --httpd is unreachable or crashed.'),
-      m('pre', message),
-    ),
-  });
-}
-
-export function showIssueParsingTheTracedResponse(message: string): void {
-  showModal({
-    title:
-      'A problem was encountered while connecting to' +
-      ' the Perfetto tracing service',
-    content: m('div', m('span', message), m('br')),
-  });
-}
-
-export function showFailedToPushBinary(message: string): void {
-  showModal({
-    title: 'Failed to push a binary to the device',
-    content: m(
-      'div',
-      m(
-        'span',
-        'This can happen if your Android device has an OS version lower ' +
-          'than Q. Perfetto tried to push the latest version of its ' +
-          'embedded binary but failed.',
-      ),
-      m('br'),
-      m('br'),
-      m('span', 'Error message:'),
-      m('br'),
-      m('span', message),
-    ),
-  });
-}
-
 function showRpcSequencingError() {
   showModal({
     title: 'A TraceProcessor RPC error occurred',
@@ -534,3 +401,25 @@
     ],
   });
 }
+
+function showWebsocketConnectionIssue(message: string): void {
+  showModal({
+    title: 'Unable to connect to the device via websocket',
+    content: m(
+      'div',
+      m('div', 'trace_processor_shell --httpd is unreachable or crashed.'),
+      m('pre', message),
+    ),
+  });
+}
+
+function showConnectionLostError(): void {
+  showModal({
+    title: 'Connection with the ADB device lost',
+    content: m(
+      'div',
+      m('span', `Please connect the device again to restart the recording.`),
+      m('br'),
+    ),
+  });
+}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 198f08e..4c87e4d 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -20,7 +20,7 @@
 import m from 'mithril';
 import {defer} from '../base/deferred';
 import {addErrorHandler, reportError} from '../base/logging';
-import {RECORDING_V2_FLAG, featureFlags} from '../core/feature_flags';
+import {featureFlags} from '../core/feature_flags';
 import {initLiveReload} from '../core/live_reload';
 import {raf} from '../core/raf_scheduler';
 import {initWasm} from '../trace_processor/wasm_engine_proxy';
@@ -33,8 +33,6 @@
 import {globals} from './globals';
 import {HomePage} from './home_page';
 import {postMessageHandler} from './post_message_handler';
-import {RecordPage} from './record_page';
-import {RecordPageV2} from './record_page_v2';
 import {Route, Router} from '../core/router';
 import {CheckHttpRpcConnection} from './rpc_http_dialog';
 import {maybeOpenTraceFromRoute} from './trace_url_handler';
@@ -65,19 +63,18 @@
 });
 
 function routeChange(route: Route) {
-  raf.scheduleFullRedraw();
-  maybeOpenTraceFromRoute(route);
-  if (route.fragment) {
-    // This needs to happen after the next redraw call. It's not enough
-    // to use setTimeout(..., 0); since that may occur before the
-    // redraw scheduled above.
-    raf.addPendingCallback(() => {
+  raf.scheduleFullRedraw(() => {
+    if (route.fragment) {
+      // This needs to happen after the next redraw call. It's not enough
+      // to use setTimeout(..., 0); since that may occur before the
+      // redraw scheduled above.
       const e = document.getElementById(route.fragment);
       if (e) {
         e.scrollIntoView();
       }
-    });
-  }
+    }
+  });
+  maybeOpenTraceFromRoute(route);
 }
 
 function setupContentSecurityPolicy() {
@@ -224,18 +221,16 @@
   const pages = AppImpl.instance.pages;
   const traceless = true;
   pages.registerPage({route: '/', traceless, page: HomePage});
-  const recordPage = RECORDING_V2_FLAG.get() ? RecordPageV2 : RecordPage;
-  pages.registerPage({route: '/record', traceless, page: recordPage});
   pages.registerPage({route: '/viewer', page: ViewerPage});
   const router = new Router();
   router.onRouteChanged = routeChange;
 
-  raf.domRedraw = () => {
+  raf.initialize(() =>
     m.render(
       document.body,
       m(UiMain, pages.renderPageForCurrentRoute(AppImpl.instance.trace)),
-    );
-  };
+    ),
+  );
 
   if (
     (location.origin.startsWith('http://localhost:') ||
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index 21dc29a..ac5b015 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -89,11 +89,11 @@
         onmousemove: (e: MouseEvent) => {
           this.mouseDragging = true;
           this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
-          raf.scheduleRedraw();
+          raf.scheduleCanvasRedraw();
         },
         onmouseenter: (e: MouseEvent) => {
           this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
-          raf.scheduleRedraw();
+          raf.scheduleCanvasRedraw();
         },
         onmouseout: () => {
           this.hoveredX = null;
diff --git a/ui/src/frontend/overview_timeline_panel.ts b/ui/src/frontend/overview_timeline_panel.ts
index e3798e1..8eb41ec 100644
--- a/ui/src/frontend/overview_timeline_panel.ts
+++ b/ui/src/frontend/overview_timeline_panel.ts
@@ -241,7 +241,7 @@
 
     const cb = (vizTime: HighPrecisionTimeSpan) => {
       this.trace.timeline.updateVisibleTimeHP(vizTime);
-      raf.scheduleRedraw();
+      raf.scheduleCanvasRedraw();
     };
     const pixelBounds = this.extractBounds(this.timeScale);
     const timeScale = this.timeScale;
@@ -445,6 +445,6 @@
         this.overviewData.get(key)!.push(value);
       }
     }
-    raf.scheduleRedraw();
+    raf.scheduleCanvasRedraw();
   }
 }
diff --git a/ui/src/frontend/pan_and_zoom_handler.ts b/ui/src/frontend/pan_and_zoom_handler.ts
index 4536b9e..0009335 100644
--- a/ui/src/frontend/pan_and_zoom_handler.ts
+++ b/ui/src/frontend/pan_and_zoom_handler.ts
@@ -259,12 +259,12 @@
   private onWheel(e: WheelEvent) {
     if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
       this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED);
-      raf.scheduleRedraw();
+      raf.scheduleCanvasRedraw();
     } else if (e.ctrlKey && this.mousePositionX !== null) {
       const sign = e.deltaY < 0 ? -1 : 1;
       const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY));
       this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED);
-      raf.scheduleRedraw();
+      raf.scheduleCanvasRedraw();
     }
   }
 
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index ab6de73..760e098 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -16,13 +16,10 @@
 import {findRef, toHTMLElement} from '../base/dom_utils';
 import {assertExists, assertFalse} from '../base/logging';
 import {
-  PerfStatsSource,
-  RunningStatistics,
-  debugNow,
-  perfDebug,
-  perfDisplay,
+  PerfStats,
+  PerfStatsContainer,
   runningStatStr,
-} from '../core/perf';
+} from '../core/perf_stats';
 import {raf} from '../core/raf_scheduler';
 import {SimpleResizeObserver} from '../base/resize_observer';
 import {canvasClip} from '../base/canvas_utils';
@@ -94,7 +91,7 @@
 }
 
 export class PanelContainer
-  implements m.ClassComponent<PanelContainerAttrs>, PerfStatsSource
+  implements m.ClassComponent<PanelContainerAttrs>, PerfStatsContainer
 {
   private readonly trace: TraceImpl;
   private attrs: PanelContainerAttrs;
@@ -105,11 +102,12 @@
   // Updated every render cycle in the oncreate/onupdate hook
   private panelInfos: PanelInfo[] = [];
 
-  private panelPerfStats = new WeakMap<Panel, RunningStatistics>();
+  private perfStatsEnabled = false;
+  private panelPerfStats = new WeakMap<Panel, PerfStats>();
   private perfStats = {
     totalPanels: 0,
     panelsOnCanvas: 0,
-    renderStats: new RunningStatistics(10),
+    renderStats: new PerfStats(10),
   };
 
   private ctx?: CanvasRenderingContext2D;
@@ -122,16 +120,8 @@
   constructor({attrs}: m.CVnode<PanelContainerAttrs>) {
     this.attrs = attrs;
     this.trace = attrs.trace;
-    const onRedraw = () => this.renderCanvas();
-    raf.addRedrawCallback(onRedraw);
-    this.trash.defer(() => {
-      raf.removeRedrawCallback(onRedraw);
-    });
-
-    perfDisplay.addContainer(this);
-    this.trash.defer(() => {
-      perfDisplay.removeContainer(this);
-    });
+    this.trash.use(raf.addCanvasRedrawCallback(() => this.renderCanvas()));
+    this.trash.use(attrs.trace.perfDebugging.addContainer(this));
   }
 
   getPanelsInRegion(
@@ -352,7 +342,7 @@
 
     const ctx = this.ctx;
     const vc = this.virtualCanvas;
-    const redrawStart = debugNow();
+    const redrawStart = performance.now();
 
     ctx.resetTransform();
     ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -367,7 +357,7 @@
     this.drawTopLayerOnCanvas(ctx, vc);
 
     // Collect performance as the last thing we do.
-    const redrawDur = debugNow() - redrawStart;
+    const redrawDur = performance.now() - redrawStart;
     this.updatePerfStats(
       redrawDur,
       this.panelInfos.length,
@@ -407,12 +397,12 @@
         ctx.save();
         ctx.translate(0, panelTop);
         canvasClip(ctx, 0, 0, panelWidth, panelHeight);
-        const beforeRender = debugNow();
+        const beforeRender = performance.now();
         panel.renderCanvas(ctx, panelSize);
         this.updatePanelStats(
           i,
           panel,
-          debugNow() - beforeRender,
+          performance.now() - beforeRender,
           ctx,
           panelSize,
         );
@@ -505,10 +495,10 @@
     ctx: CanvasRenderingContext2D,
     size: Size2D,
   ) {
-    if (!perfDebug()) return;
+    if (!this.perfStatsEnabled) return;
     let renderStats = this.panelPerfStats.get(panel);
     if (renderStats === undefined) {
-      renderStats = new RunningStatistics();
+      renderStats = new PerfStats();
       this.panelPerfStats.set(panel, renderStats);
     }
     renderStats.addValue(renderTime);
@@ -537,12 +527,16 @@
     totalPanels: number,
     panelsOnCanvas: number,
   ) {
-    if (!perfDebug()) return;
+    if (!this.perfStatsEnabled) return;
     this.perfStats.renderStats.addValue(renderTime);
     this.perfStats.totalPanels = totalPanels;
     this.perfStats.panelsOnCanvas = panelsOnCanvas;
   }
 
+  setPerfStatsEnabled(enable: boolean): void {
+    this.perfStatsEnabled = enable;
+  }
+
   renderPerfStats() {
     return [
       m(
diff --git a/ui/src/frontend/pivot_table.ts b/ui/src/frontend/pivot_table.ts
index a096140..925cfed 100644
--- a/ui/src/frontend/pivot_table.ts
+++ b/ui/src/frontend/pivot_table.ts
@@ -34,11 +34,6 @@
   sliceAggregationColumns,
   tables,
 } from '../core/pivot_table_query_generator';
-import {
-  PopupMenuButton,
-  popupMenuIcon,
-  PopupMenuItem,
-} from '../widgets/popup_menu';
 import {ReorderableCell, ReorderableCellGroup} from './reorderable_cells';
 import {AttributeModalHolder} from './tables/attribute_modal_holder';
 import {DurationWidget} from './widgets/duration';
@@ -49,6 +44,9 @@
 import {TraceImpl} from '../core/trace_impl';
 import {PivotTableManager} from '../core/pivot_table_manager';
 import {extensions} from '../public/lib/extensions';
+import {MenuItem, PopupMenu2} from '../widgets/menu';
+import {Button} from '../widgets/button';
+import {popupMenuIcon} from '../widgets/table';
 
 interface PathItem {
   tree: PivotTree;
@@ -293,15 +291,14 @@
     return m('tr', overallValuesRow);
   }
 
-  sortingItem(aggregationIndex: number, order: SortDirection): PopupMenuItem {
+  sortingItem(aggregationIndex: number, order: SortDirection): m.Child {
     const pivotMgr = this.pivotMgr;
-    return {
-      itemType: 'regular',
-      text: order === 'DESC' ? 'Highest first' : 'Lowest first',
-      callback() {
+    return m(MenuItem, {
+      label: order === 'DESC' ? 'Highest first' : 'Lowest first',
+      onclick: () => {
         pivotMgr.setSortColumn(aggregationIndex, order);
       },
-    };
+    });
   }
 
   readableAggregationName(aggregation: Aggregation) {
@@ -317,20 +314,21 @@
     aggregation: Aggregation,
     index: number,
     nameOverride?: string,
-  ): PopupMenuItem {
-    return {
-      itemType: 'regular',
-      text: nameOverride ?? readableColumnName(aggregation.column),
-      callback: () => this.pivotMgr.addAggregation(aggregation, index),
-    };
+  ): m.Child {
+    return m(MenuItem, {
+      label: nameOverride ?? readableColumnName(aggregation.column),
+      onclick: () => {
+        this.pivotMgr.addAggregation(aggregation, index);
+      },
+    });
   }
 
   aggregationPopupTableGroup(
     table: string,
     columns: string[],
     index: number,
-  ): PopupMenuItem | undefined {
-    const items = [];
+  ): m.Child | undefined {
+    const items: m.Child[] = [];
     for (const column of columns) {
       const tableColumn: TableColumn = {kind: 'regular', table, column};
       items.push(
@@ -345,12 +343,7 @@
       return undefined;
     }
 
-    return {
-      itemType: 'group',
-      itemId: `aggregations-${table}`,
-      text: `Add ${table} aggregation`,
-      children: items,
-    };
+    return m(MenuItem, {label: `Add ${table} aggregation`}, items);
   }
 
   renderAggregationHeaderCell(
@@ -358,7 +351,7 @@
     index: number,
     removeItem: boolean,
   ): ReorderableCell {
-    const popupItems: PopupMenuItem[] = [];
+    const popupItems: m.Child[] = [];
     if (aggregation.sortDirection === undefined) {
       popupItems.push(
         this.sortingItem(index, 'DESC'),
@@ -381,22 +374,26 @@
           continue;
         }
         const pivotMgr = this.pivotMgr;
-        popupItems.push({
-          itemType: 'regular',
-          text: otherAgg,
-          callback() {
-            pivotMgr.setAggregationFunction(index, otherAgg);
-          },
-        });
+        popupItems.push(
+          m(MenuItem, {
+            label: otherAgg,
+            onclick: () => {
+              pivotMgr.setAggregationFunction(index, otherAgg);
+            },
+          }),
+        );
       }
     }
 
     if (removeItem) {
-      popupItems.push({
-        itemType: 'regular',
-        text: 'Remove',
-        callback: () => this.pivotMgr.removeAggregation(index),
-      });
+      popupItems.push(
+        m(MenuItem, {
+          label: 'Remove',
+          onclick: () => {
+            this.pivotMgr.removeAggregation(index);
+          },
+        }),
+      );
     }
 
     let hasCount = false;
@@ -429,10 +426,15 @@
       extraClass: '.aggregation' + markFirst(index),
       content: [
         this.readableAggregationName(aggregation),
-        m(PopupMenuButton, {
-          icon: popupMenuIcon(aggregation.sortDirection),
-          items: popupItems,
-        }),
+        m(
+          PopupMenu2,
+          {
+            trigger: m(Button, {
+              icon: popupMenuIcon(aggregation.sortDirection),
+            }),
+          },
+          popupItems,
+        ),
       ],
     };
   }
@@ -445,27 +447,27 @@
     selectedPivots: Set<string>,
   ): ReorderableCell {
     const pivotMgr = this.pivotMgr;
-    const items: PopupMenuItem[] = [
-      {
-        itemType: 'regular',
-        text: 'Add argument pivot',
-        callback: () => {
+    const items: m.Child[] = [
+      m(MenuItem, {
+        label: 'Add argument pivot',
+        onclick: () => {
           this.attributeModalHolder.start();
         },
-      },
+      }),
     ];
     if (queryResult.metadata.pivotColumns.length > 1) {
-      items.push({
-        itemType: 'regular',
-        text: 'Remove',
-        callback() {
-          pivotMgr.setPivotSelected({column: pivot, selected: false});
-        },
-      });
+      items.push(
+        m(MenuItem, {
+          label: 'Remove',
+          onclick: () => {
+            pivotMgr.setPivotSelected({column: pivot, selected: false});
+          },
+        }),
+      );
     }
 
     for (const table of tables) {
-      const group: PopupMenuItem[] = [];
+      const group: m.Child[] = [];
       for (const columnName of table.columns) {
         const column: TableColumn = {
           kind: 'regular',
@@ -475,26 +477,30 @@
         if (selectedPivots.has(columnKey(column))) {
           continue;
         }
-        group.push({
-          itemType: 'regular',
-          text: columnName,
-          callback() {
-            pivotMgr.setPivotSelected({column, selected: true});
-          },
-        });
+        group.push(
+          m(MenuItem, {
+            label: columnName,
+            onclick: () => {
+              pivotMgr.setPivotSelected({column, selected: true});
+            },
+          }),
+        );
       }
-      items.push({
-        itemType: 'group',
-        itemId: `pivot-${table.name}`,
-        text: `Add ${table.displayName} pivot`,
-        children: group,
-      });
+      items.push(
+        m(
+          MenuItem,
+          {
+            label: `Add ${table.displayName} pivot`,
+          },
+          group,
+        ),
+      );
     }
 
     return {
       content: [
         readableColumnName(pivot),
-        m(PopupMenuButton, {icon: 'more_horiz', items}),
+        m(PopupMenu2, {trigger: m(Button, {icon: 'more_horiz'})}, items),
       ],
     };
   }
@@ -551,20 +557,20 @@
           }),
           m(
             'td.menu',
-            m(PopupMenuButton, {
-              icon: 'menu',
-              items: [
-                {
-                  itemType: 'regular',
-                  text: state.constrainToArea
-                    ? 'Query data for the whole timeline'
-                    : 'Constrain to selected area',
-                  callback: () => {
-                    this.pivotMgr.setConstrainedToArea(!state.constrainToArea);
-                  },
+            m(
+              PopupMenu2,
+              {
+                trigger: m(Button, {icon: 'menu'}),
+              },
+              m(MenuItem, {
+                label: state.constrainToArea
+                  ? 'Query data for the whole timeline'
+                  : 'Constrain to selected area',
+                onclick: () => {
+                  this.pivotMgr.setConstrainedToArea(!state.constrainToArea);
                 },
-              ],
-            }),
+              }),
+            ),
           ),
         ),
       ),
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 9f71bcc..8ece2d3 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 import {getCurrentChannel} from '../core/channels';
-import {TRACE_SUFFIX} from '../common/constants';
+import {TRACE_SUFFIX} from '../public/trace';
 import {
   disableMetatracingAndGetTrace,
   enableMetatracing,
@@ -554,13 +554,6 @@
   // TODO(primiano): The Open file / Open with legacy entries are registered by
   // the 'perfetto.CoreCommands' plugins. Make things consistent.
   app.sidebar.addMenuItem({
-    section: 'navigation',
-    text: 'Record new trace',
-    href: '#!/record',
-    icon: 'fiber_smart_record',
-    sortOrder: 2,
-  });
-  app.sidebar.addMenuItem({
     section: 'support',
     text: 'Keyboard shortcuts',
     action: toggleHelp,
diff --git a/ui/src/frontend/sql_table_tab.ts b/ui/src/frontend/sql_table_tab.ts
index 40f2012..b803148 100644
--- a/ui/src/frontend/sql_table_tab.ts
+++ b/ui/src/frontend/sql_table_tab.ts
@@ -28,6 +28,12 @@
 import {MenuItem, PopupMenu2} from '../widgets/menu';
 import {addEphemeralTab} from '../common/add_ephemeral_tab';
 import {Tab} from '../public/tab';
+import {addChartTab} from './widgets/charts/chart_tab';
+import {
+  ChartOption,
+  createChartConfigFromSqlTableState,
+} from './widgets/charts/chart';
+import {AddChartMenuItem} from './widgets/charts/add_chart_menu';
 
 export interface AddSqlTableTabParams {
   table: SqlTableDescription;
@@ -122,6 +128,16 @@
       },
       m(SqlTable, {
         state: this.state,
+        addColumnMenuItems: (column, columnAlias) =>
+          m(AddChartMenuItem, {
+            chartConfig: createChartConfigFromSqlTableState(
+              column,
+              columnAlias,
+              this.state,
+            ),
+            chartOptions: [ChartOption.HISTOGRAM],
+            addChart: (chart) => addChartTab(chart),
+          }),
       }),
     );
   }
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 7814674..fcae30c 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -133,15 +133,15 @@
           ...pos,
           timescale,
         });
-        raf.scheduleRedraw();
+        raf.scheduleCanvasRedraw();
       },
       onTrackContentMouseOut: () => {
         trackRenderer?.track.onMouseOut?.();
-        raf.scheduleRedraw();
+        raf.scheduleCanvasRedraw();
       },
       onTrackContentClick: (pos, bounds) => {
         const timescale = this.getTimescaleForBounds(bounds);
-        raf.scheduleRedraw();
+        raf.scheduleCanvasRedraw();
         return (
           trackRenderer?.track.onMouseClick?.({
             ...pos,
diff --git a/ui/src/frontend/ui_main.ts b/ui/src/frontend/ui_main.ts
index 954da1a..c67f5b7 100644
--- a/ui/src/frontend/ui_main.ts
+++ b/ui/src/frontend/ui_main.ts
@@ -171,7 +171,8 @@
       {
         id: 'perfetto.TogglePerformanceMetrics',
         name: 'Toggle performance metrics',
-        callback: () => app.setPerfDebuggingEnabled(!app.perfDebugging),
+        callback: () =>
+          (app.perfDebugging.enabled = !app.perfDebugging.enabled),
       },
       {
         id: 'perfetto.ShareTrace',
@@ -652,7 +653,7 @@
         children,
         m(CookieConsent),
         maybeRenderFullscreenModalDialog(),
-        AppImpl.instance.perfDebugging && m('.perf-stats'),
+        AppImpl.instance.perfDebugging.renderPerfStats(),
       ),
     );
   }
diff --git a/ui/src/frontend/value.ts b/ui/src/frontend/value.ts
index 40ad1f4..a57f2ea 100644
--- a/ui/src/frontend/value.ts
+++ b/ui/src/frontend/value.ts
@@ -14,7 +14,8 @@
 
 import m from 'mithril';
 import {Tree, TreeNode} from '../widgets/tree';
-import {PopupMenuButton, PopupMenuItem} from '../widgets/popup_menu';
+import {PopupMenu2} from '../widgets/menu';
+import {Button} from '../widgets/button';
 
 // This file implements a component for rendering JSON-like values (with
 // customisation options like context menu and action buttons).
@@ -109,7 +110,7 @@
 
 // Customisation parameters which apply to any Value (e.g. context menu).
 interface ValueParams {
-  contextMenu?: PopupMenuItem[];
+  contextMenu?: m.Child[];
 }
 
 // Customisation parameters which apply for a primitive value (e.g. showing
@@ -137,10 +138,15 @@
   const left = [
     name,
     value.contextMenu
-      ? m(PopupMenuButton, {
-          icon: 'arrow_drop_down',
-          items: value.contextMenu,
-        })
+      ? m(
+          PopupMenu2,
+          {
+            trigger: m(Button, {
+              icon: 'arrow_drop_down',
+            }),
+          },
+          value.contextMenu,
+        )
       : null,
   ];
   if (isArray(value)) {
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index 75f2aba..9509c1d 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -145,7 +145,7 @@
         const rect = dom.getBoundingClientRect();
         const centerPoint = zoomPx / (rect.width - TRACK_SHELL_WIDTH);
         timeline.zoomVisibleWindow(1 - zoomRatio, centerPoint);
-        raf.scheduleRedraw();
+        raf.scheduleCanvasRedraw();
       },
       editSelection: (currentPx: number) => {
         if (this.timelineWidthPx === undefined) return false;
@@ -257,7 +257,7 @@
           }
           this.showPanningHint = true;
         }
-        raf.scheduleRedraw();
+        raf.scheduleCanvasRedraw();
       },
       endSelection: (edit: boolean) => {
         this.selectedContainer = undefined;
diff --git a/ui/src/frontend/widgets/charts/add_chart_menu.ts b/ui/src/frontend/widgets/charts/add_chart_menu.ts
index 384ed9a..cb3bb17 100644
--- a/ui/src/frontend/widgets/charts/add_chart_menu.ts
+++ b/ui/src/frontend/widgets/charts/add_chart_menu.ts
@@ -15,12 +15,12 @@
 import m from 'mithril';
 import {MenuItem} from '../../../widgets/menu';
 import {Icons} from '../../../base/semantic_icons';
-import {ChartConfig, ChartOption, toTitleCase} from './chart';
+import {Chart, ChartConfig, ChartOption, toTitleCase} from './chart';
 
 interface AddChartMenuItemAttrs {
   readonly chartConfig: ChartConfig;
   readonly chartOptions: Array<ChartOption>;
-  readonly addChart: (option: ChartOption, config: ChartConfig) => void;
+  readonly addChart: (chart: Chart) => void;
 }
 
 export class AddChartMenuItem
@@ -29,12 +29,12 @@
   private renderAddChartOptions(
     config: ChartConfig,
     chartOptions: Array<ChartOption>,
-    addChart: (option: ChartOption, config: ChartConfig) => void,
+    addChart: (chart: Chart) => void,
   ): m.Children {
     return chartOptions.map((option) => {
       return m(MenuItem, {
         label: toTitleCase(option),
-        onclick: () => addChart(option, config),
+        onclick: () => addChart({option, config}),
       });
     });
   }
diff --git a/ui/src/frontend/widgets/charts/chart.ts b/ui/src/frontend/widgets/charts/chart.ts
index ccbf566..124a52e 100644
--- a/ui/src/frontend/widgets/charts/chart.ts
+++ b/ui/src/frontend/widgets/charts/chart.ts
@@ -16,6 +16,8 @@
 import {Engine} from '../../../trace_processor/engine';
 import {Filter, TableColumn, TableColumnSet} from '../sql/table/column';
 import {Histogram} from './histogram/histogram';
+import {SqlTableState} from '../sql/table/state';
+import {columnTitle} from '../sql/table/table';
 
 export interface VegaLiteChartSpec {
   $schema: string;
@@ -58,6 +60,11 @@
   readonly aggregationType?: 'nominal' | 'quantitative'; // Aggregation type.
 }
 
+export interface Chart {
+  readonly option: ChartOption;
+  readonly config: ChartConfig;
+}
+
 export interface ChartData {
   readonly rows: Row[];
   readonly error?: string;
@@ -85,11 +92,29 @@
 
 // renderChartComponent will take a chart option and config and map
 // to the corresponding chart class component.
-export function renderChartComponent(option: ChartOption, config: ChartConfig) {
-  switch (option) {
+export function renderChartComponent(chart: Chart) {
+  switch (chart.option) {
     case ChartOption.HISTOGRAM:
-      return m(Histogram, config);
+      return m(Histogram, chart.config);
     default:
       return;
   }
 }
+
+export function createChartConfigFromSqlTableState(
+  column: TableColumn,
+  columnAlias: string,
+  sqlTableState: SqlTableState,
+) {
+  return {
+    engine: sqlTableState.trace.engine,
+    columnTitle: columnTitle(column),
+    sqlColumn: [columnAlias],
+    filters: sqlTableState?.getFilters(),
+    tableDisplay: sqlTableState.config.displayName ?? sqlTableState.config.name,
+    query: sqlTableState.getSqlQuery(
+      Object.fromEntries([[columnAlias, column.primaryColumn()]]),
+    ),
+    aggregationType: column.aggregation?.().dataType,
+  };
+}
diff --git a/ui/src/frontend/widgets/charts/chart_tab.ts b/ui/src/frontend/widgets/charts/chart_tab.ts
index 0dcc6d9..6d802e6 100644
--- a/ui/src/frontend/widgets/charts/chart_tab.ts
+++ b/ui/src/frontend/widgets/charts/chart_tab.ts
@@ -17,25 +17,14 @@
 import {filterTitle} from '../sql/table/column';
 import {addEphemeralTab} from '../../../common/add_ephemeral_tab';
 import {Tab} from '../../../public/tab';
-import {
-  ChartConfig,
-  ChartOption,
-  renderChartComponent,
-  toTitleCase,
-} from './chart';
+import {Chart, renderChartComponent, toTitleCase} from './chart';
 
-export function addChartTab(
-  chartOption: ChartOption,
-  chartConfig: ChartConfig,
-): void {
-  addEphemeralTab('histogramTab', new ChartTab(chartOption, chartConfig));
+export function addChartTab(chart: Chart): void {
+  addEphemeralTab('histogramTab', new ChartTab(chart));
 }
 
 export class ChartTab implements Tab {
-  constructor(
-    private readonly chartOption: ChartOption,
-    private readonly chartConfig: ChartConfig,
-  ) {}
+  constructor(private readonly chart: Chart) {}
 
   render() {
     return m(
@@ -44,20 +33,20 @@
         title: this.getTitle(),
         description: this.getDescription(),
       },
-      renderChartComponent(this.chartOption, this.chartConfig),
+      renderChartComponent(this.chart),
     );
   }
 
   getTitle(): string {
-    return `${toTitleCase(this.chartConfig.columnTitle)} Histogram`;
+    return `${toTitleCase(this.chart.config.columnTitle)} Histogram`;
   }
 
   private getDescription(): string {
-    let desc = `Count distribution for ${this.chartConfig.tableDisplay ?? ''} table`;
+    let desc = `Count distribution for ${this.chart.config.tableDisplay ?? ''} table`;
 
-    if (this.chartConfig.filters && this.chartConfig.filters.length > 0) {
+    if (this.chart.config.filters && this.chart.config.filters.length > 0) {
       desc += ' where ';
-      desc += this.chartConfig.filters.map((f) => filterTitle(f)).join(', ');
+      desc += this.chart.config.filters.map((f) => filterTitle(f)).join(', ');
     }
 
     return desc;
diff --git a/ui/src/frontend/widgets/sql/table/state.ts b/ui/src/frontend/widgets/sql/table/state.ts
index 1f513b8..4540214 100644
--- a/ui/src/frontend/widgets/sql/table/state.ts
+++ b/ui/src/frontend/widgets/sql/table/state.ts
@@ -331,8 +331,15 @@
       this.rowCount = undefined;
     }
 
-    // Run a delayed UI update to avoid flickering if the query returns quickly.
-    raf.scheduleDelayedFullRedraw();
+    // Schedule a full redraw to happen after a short delay (50 ms).
+    // This is done to prevent flickering / visual noise and allow the UI to fetch
+    // the initial data from the Trace Processor.
+    // There is a chance that someone else schedules a full redraw in the
+    // meantime, forcing the flicker, but in practice it works quite well and
+    // avoids a lot of complexity for the callers.
+    // 50ms is half of the responsiveness threshold (100ms):
+    // https://web.dev/rail/#response-process-events-in-under-50ms
+    setTimeout(() => raf.scheduleFullRedraw(), 50);
 
     if (!filtersMatch) {
       this.rowCount = await this.loadRowCount();
diff --git a/ui/src/frontend/widgets/sql/table/table.ts b/ui/src/frontend/widgets/sql/table/table.ts
index 28cc81c..761a32c 100644
--- a/ui/src/frontend/widgets/sql/table/table.ts
+++ b/ui/src/frontend/widgets/sql/table/table.ts
@@ -40,16 +40,20 @@
 import {SqlTableState} from './state';
 import {SqlTableDescription} from './table_description';
 import {Intent} from '../../../../widgets/common';
-import {addChartTab} from '../../charts/chart_tab';
 import {Form} from '../../../../widgets/form';
 import {TextInput} from '../../../../widgets/text_input';
-import {AddChartMenuItem} from '../../charts/add_chart_menu';
-import {ChartConfig, ChartOption} from '../../charts/chart';
 
 export interface SqlTableConfig {
   readonly state: SqlTableState;
+  // For additional menu items to add to the column header menus
+  readonly addColumnMenuItems?: (
+    column: TableColumn,
+    columnAlias: string,
+  ) => m.Children;
 }
 
+type AdditionalColumnMenuItems = Record<string, m.Children>;
+
 function renderCell(
   column: TableColumn,
   row: Row,
@@ -276,7 +280,11 @@
     );
   }
 
-  renderColumnHeader(column: TableColumn, index: number) {
+  renderColumnHeader(
+    column: TableColumn,
+    index: number,
+    additionalColumnHeaderMenuItems?: m.Children,
+  ) {
     const sorted = this.state.isSortedBy(column);
     const icon =
       sorted === 'ASC'
@@ -285,22 +293,6 @@
           ? Icons.SortedDesc
           : Icons.ContextMenu;
 
-    const columnAlias =
-      this.state.getCurrentRequest().columns[
-        sqlColumnId(column.primaryColumn())
-      ];
-    const chartConfig: ChartConfig = {
-      engine: this.state.trace.engine,
-      columnTitle: columnTitle(column),
-      sqlColumn: [columnAlias],
-      filters: this.state.getFilters(),
-      tableDisplay: this.table.displayName ?? this.table.name,
-      query: this.state.getSqlQuery(
-        Object.fromEntries([[columnAlias, column.primaryColumn()]]),
-      ),
-      aggregationType: column.aggregation?.().dataType,
-    };
-
     return m(
       PopupMenu2,
       {
@@ -345,11 +337,7 @@
         {label: 'Add filter', icon: Icons.Filter},
         this.renderColumnFilterOptions(column),
       ),
-      m(AddChartMenuItem, {
-        chartConfig,
-        chartOptions: [ChartOption.HISTOGRAM],
-        addChart: (option, config) => addChartTab(option, config),
-      }),
+      additionalColumnHeaderMenuItems,
       // Menu items before divider apply to selected column
       m(MenuDivider),
       // Menu items after divider apply to entire table
@@ -357,13 +345,49 @@
     );
   }
 
-  view() {
+  getAdditionalColumnMenuItems(
+    addColumnMenuItems?: (
+      column: TableColumn,
+      columnAlias: string,
+    ) => m.Children,
+  ) {
+    if (addColumnMenuItems === undefined) return;
+
+    const additionalColumnMenuItems: AdditionalColumnMenuItems = {};
+    this.state.getSelectedColumns().forEach((column) => {
+      const columnAlias =
+        this.state.getCurrentRequest().columns[
+          sqlColumnId(column.primaryColumn())
+        ];
+
+      additionalColumnMenuItems[columnAlias] = addColumnMenuItems(
+        column,
+        columnAlias,
+      );
+    });
+
+    return additionalColumnMenuItems;
+  }
+
+  view({attrs}: m.Vnode<SqlTableConfig>) {
     const rows = this.state.getDisplayedRows();
+    const additionalColumnMenuItems = this.getAdditionalColumnMenuItems(
+      attrs.addColumnMenuItems,
+    );
 
     const columns = this.state.getSelectedColumns();
     const columnDescriptors = columns.map((column, i) => {
       return {
-        title: this.renderColumnHeader(column, i),
+        title: this.renderColumnHeader(
+          column,
+          i,
+          additionalColumnMenuItems &&
+            additionalColumnMenuItems[
+              this.state.getCurrentRequest().columns[
+                sqlColumnId(column.primaryColumn())
+              ]
+            ],
+        ),
         render: (row: Row) => renderCell(column, row, this.state),
       };
     });
diff --git a/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts b/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
index 5b1d0d4..1c60d4f 100644
--- a/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
+++ b/ui/src/plugins/dev.perfetto.ExplorePage/explore_page.ts
@@ -33,8 +33,15 @@
 import {Button} from '../../widgets/button';
 import {Icons} from '../../base/semantic_icons';
 import {DetailsShell} from '../../widgets/details_shell';
+import {
+  Chart,
+  ChartOption,
+  createChartConfigFromSqlTableState,
+  renderChartComponent,
+} from '../../frontend/widgets/charts/chart';
+import {AddChartMenuItem} from '../../frontend/widgets/charts/add_chart_menu';
 
-interface ExplorePageState {
+interface ExploreTableState {
   sqlTableState?: SqlTableState;
   selectedTable?: ExplorableTable;
 }
@@ -46,13 +53,12 @@
 }
 
 export class ExplorePage implements m.ClassComponent<PageWithTraceAttrs> {
-  private readonly state: ExplorePageState;
+  private readonly state: ExploreTableState;
+  private readonly charts: Chart[];
 
   constructor() {
-    this.state = {
-      sqlTableState: undefined,
-      selectedTable: undefined,
-    };
+    this.charts = [];
+    this.state = {};
   }
 
   // Show menu with standard library tables
@@ -115,7 +121,7 @@
 
           this.state.selectedTable = table;
 
-          const sqlTableState = new SqlTableState(
+          this.state.sqlTableState = new SqlTableState(
             trace,
             {
               name: table.name,
@@ -123,7 +129,6 @@
             },
             {imports: [table.module]},
           );
-          this.state.sqlTableState = sqlTableState;
         },
       });
     });
@@ -162,6 +167,16 @@
       },
       m(SqlTable, {
         state: sqlTableState,
+        addColumnMenuItems: (column, columnAlias) =>
+          m(AddChartMenuItem, {
+            chartConfig: createChartConfigFromSqlTableState(
+              column,
+              columnAlias,
+              sqlTableState,
+            ),
+            chartOptions: [ChartOption.HISTOGRAM],
+            addChart: (chart) => this.charts.push(chart),
+          }),
       }),
     );
   }
@@ -170,6 +185,7 @@
     return m(
       '.explore-page',
       m(Menu, this.renderSelectableTablesMenuItems(attrs.trace)),
+      this.charts.map((chart) => renderChartComponent(chart)),
       this.state.selectedTable && this.renderSqlTable(),
     );
   }
diff --git a/ui/src/controller/adb.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb.ts
similarity index 98%
rename from ui/src/controller/adb.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb.ts
index e188ea7..5197d23 100644
--- a/ui/src/controller/adb.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb.ts
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertExists} from '../base/logging';
-import {isString} from '../base/object_utils';
-import {utf8Decode, utf8Encode} from '../base/string_utils';
+import {assertExists} from '../../base/logging';
+import {isString} from '../../base/object_utils';
+import {utf8Decode, utf8Encode} from '../../base/string_utils';
 import {Adb, AdbMsg, AdbStream, CmdType} from './adb_interfaces';
 
 export const VERSION_WITH_CHECKSUM = 0x01000000;
diff --git a/ui/src/controller/adb_base_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_base_controller.ts
similarity index 96%
rename from ui/src/controller/adb_base_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_base_controller.ts
index 2a72a33..c447df5 100644
--- a/ui/src/controller/adb_base_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb_base_controller.ts
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {exists} from '../base/utils';
-import {RecordingState, RecordingTarget, isAdbTarget} from '../common/state';
+import {exists} from '../../base/utils';
+import {RecordingState, RecordingTarget, isAdbTarget} from './state';
 import {
   extractDurationFromTraceConfig,
   extractTraceConfig,
-} from '../core/trace_config_utils';
+} from './trace_config_utils';
 import {Adb} from './adb_interfaces';
 import {ReadBuffersResponse} from './consumer_port_types';
 import {Consumer, RpcConsumerPort} from './record_controller_interfaces';
diff --git a/ui/src/controller/adb_interfaces.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_interfaces.ts
similarity index 100%
rename from ui/src/controller/adb_interfaces.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_interfaces.ts
diff --git a/ui/src/controller/adb_jsdomtest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_jsdomtest.ts
similarity index 97%
rename from ui/src/controller/adb_jsdomtest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_jsdomtest.ts
index 9f51a97..1d228a5 100644
--- a/ui/src/controller/adb_jsdomtest.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb_jsdomtest.ts
@@ -19,7 +19,7 @@
   DEFAULT_MAX_PAYLOAD_BYTES,
   VERSION_WITH_CHECKSUM,
 } from './adb';
-import {utf8Encode} from '../base/string_utils';
+import {utf8Encode} from '../../base/string_utils';
 
 test('startAuthentication', async () => {
   const adb = new AdbOverWebUsb();
diff --git a/ui/src/controller/adb_record_controller_jsdomtest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_record_controller_jsdomtest.ts
similarity index 95%
rename from ui/src/controller/adb_record_controller_jsdomtest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_record_controller_jsdomtest.ts
index e404397..6078a59 100644
--- a/ui/src/controller/adb_record_controller_jsdomtest.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb_record_controller_jsdomtest.ts
@@ -13,12 +13,12 @@
 // limitations under the License.
 
 import {dingus} from 'dingusjs';
-import {utf8Encode} from '../base/string_utils';
-import {EnableTracingRequest, TraceConfig} from '../protos';
+import {utf8Encode} from '../../base/string_utils';
+import {EnableTracingRequest, TraceConfig} from '../../protos';
 import {AdbStream, MockAdb, MockAdbStream} from './adb_interfaces';
 import {AdbConsumerPort} from './adb_shell_controller';
 import {Consumer} from './record_controller_interfaces';
-import {createEmptyState} from '../common/empty_state';
+import {createEmptyState} from './empty_state';
 
 function generateMockConsumer(): Consumer {
   return {
diff --git a/ui/src/controller/adb_shell_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_shell_controller.ts
similarity index 96%
rename from ui/src/controller/adb_shell_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_shell_controller.ts
index 5d1d156..623dc5d 100644
--- a/ui/src/controller/adb_shell_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb_shell_controller.ts
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {base64Encode, utf8Decode} from '../base/string_utils';
-import {RecordingState} from '../common/state';
-import {extractTraceConfig} from '../core/trace_config_utils';
+import {base64Encode, utf8Decode} from '../../base/string_utils';
+import {RecordingState} from './state';
+import {extractTraceConfig} from './trace_config_utils';
 import {AdbBaseConsumerPort, AdbConnectionState} from './adb_base_controller';
 import {Adb, AdbStream} from './adb_interfaces';
 import {ReadBuffersResponse} from './consumer_port_types';
diff --git a/ui/src/controller/adb_socket_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/adb_socket_controller.ts
similarity index 98%
rename from ui/src/controller/adb_socket_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/adb_socket_controller.ts
index 715be0d..a676747 100644
--- a/ui/src/controller/adb_socket_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/adb_socket_controller.ts
@@ -20,14 +20,14 @@
   GetTraceStatsResponse,
   IPCFrame,
   ReadBuffersResponse,
-} from '../protos';
+} from '../../protos';
 import {AdbBaseConsumerPort, AdbConnectionState} from './adb_base_controller';
 import {Adb, AdbStream} from './adb_interfaces';
 import {isReadBuffersResponse} from './consumer_port_types';
 import {Consumer} from './record_controller_interfaces';
-import {exists} from '../base/utils';
-import {assertTrue} from '../base/logging';
-import {RecordingState} from '../common/state';
+import {exists} from '../../base/utils';
+import {assertTrue} from '../../base/logging';
+import {RecordingState} from './state';
 
 enum SocketState {
   DISCONNECTED,
diff --git a/ui/src/frontend/recording/advanced_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/advanced_settings.ts
similarity index 97%
rename from ui/src/frontend/recording/advanced_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/advanced_settings.ts
index d762338..35e6fe2 100644
--- a/ui/src/frontend/recording/advanced_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/advanced_settings.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Dropdown, Probe, Slider, Textarea, Toggle} from '../record_widgets';
+import {Dropdown, Probe, Slider, Textarea, Toggle} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 
 const FTRACE_CATEGORIES = new Map<string, string>();
diff --git a/ui/src/frontend/recording/android_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/android_settings.ts
similarity index 98%
rename from ui/src/frontend/recording/android_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/android_settings.ts
index 4154fa7..7c0d741 100644
--- a/ui/src/frontend/recording/android_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/android_settings.ts
@@ -14,9 +14,9 @@
 
 import m from 'mithril';
 import {AtomId, DataSourceDescriptor} from '../../protos';
-import {Dropdown, Probe, Slider, Textarea, Toggle} from '../record_widgets';
+import {Dropdown, Probe, Slider, Textarea, Toggle} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
-import {RecordConfig} from '../../controller/record_config_types';
+import {RecordConfig} from './record_config_types';
 
 const PUSH_ATOM_IDS = new Map<string, string>();
 const PULL_ATOM_IDS = new Map<string, string>();
diff --git a/ui/src/controller/chrome_proxy_record_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/chrome_proxy_record_controller.ts
similarity index 96%
rename from ui/src/controller/chrome_proxy_record_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/chrome_proxy_record_controller.ts
index d1e1b63..ef0b999 100644
--- a/ui/src/controller/chrome_proxy_record_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/chrome_proxy_record_controller.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {binaryDecode, binaryEncode} from '../base/string_utils';
-import {TRACE_SUFFIX} from '../common/constants';
+import {binaryDecode, binaryEncode} from '../../base/string_utils';
+import {TRACE_SUFFIX} from '../../public/trace';
 import {
   ConsumerPortResponse,
   hasProperty,
diff --git a/ui/src/frontend/recording/chrome_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/chrome_settings.ts
similarity index 97%
rename from ui/src/frontend/recording/chrome_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/chrome_settings.ts
index 8ac14cd..fd09d82 100644
--- a/ui/src/frontend/recording/chrome_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/chrome_settings.ts
@@ -13,19 +13,19 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {DataSource} from '../../common/recordingV2/recording_interfaces_v2';
+import {DataSource} from './recordingV2/recording_interfaces_v2';
 import {
   RecordingState,
   getBuiltinChromeCategoryList,
   isChromeTarget,
-} from '../../common/state';
+} from './state';
 import {
   MultiSelect,
   MultiSelectDiff,
   Option as MultiSelectOption,
 } from '../../widgets/multiselect';
 import {Section} from '../../widgets/section';
-import {CategoryGetter, CompactProbe, Toggle} from '../record_widgets';
+import {CategoryGetter, CompactProbe, Toggle} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 
 function extractChromeCategories(
diff --git a/ui/src/controller/consumer_port_types.ts b/ui/src/plugins/dev.perfetto.RecordTrace/consumer_port_types.ts
similarity index 98%
rename from ui/src/controller/consumer_port_types.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/consumer_port_types.ts
index 973205f..732e9e8 100644
--- a/ui/src/controller/consumer_port_types.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/consumer_port_types.ts
@@ -18,7 +18,7 @@
   IFreeBuffersResponse,
   IGetTraceStatsResponse,
   IReadBuffersResponse,
-} from '../protos';
+} from '../../protos';
 
 export interface Typed {
   type: string;
diff --git a/ui/src/frontend/recording/cpu_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/cpu_settings.ts
similarity index 97%
rename from ui/src/frontend/recording/cpu_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/cpu_settings.ts
index ba12267..06b2713 100644
--- a/ui/src/frontend/recording/cpu_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/cpu_settings.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Probe, Slider} from '../record_widgets';
+import {Probe, Slider} from './record_widgets';
 import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
 
 export class CpuSettings implements m.ClassComponent<RecordingSectionAttrs> {
diff --git a/ui/src/common/empty_state.ts b/ui/src/plugins/dev.perfetto.RecordTrace/empty_state.ts
similarity index 92%
rename from ui/src/common/empty_state.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/empty_state.ts
index c356b00..bfafe3f 100644
--- a/ui/src/common/empty_state.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/empty_state.ts
@@ -12,10 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {
-  autosaveConfigStore,
-  recordTargetStore,
-} from '../frontend/record_config';
+import {autosaveConfigStore, recordTargetStore} from './record_config';
 import {RecordingState} from './state';
 
 export function createEmptyState(): RecordingState {
diff --git a/ui/src/frontend/recording/etw_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/etw_settings.ts
similarity index 96%
rename from ui/src/frontend/recording/etw_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/etw_settings.ts
index 70c1ab1..eefb8ac 100644
--- a/ui/src/frontend/recording/etw_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/etw_settings.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Probe} from '../record_widgets';
+import {Probe} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 
 export class EtwSettings implements m.ClassComponent<RecordingSectionAttrs> {
diff --git a/ui/src/frontend/recording/gpu_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/gpu_settings.ts
similarity index 97%
rename from ui/src/frontend/recording/gpu_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/gpu_settings.ts
index 745af99..1040f75 100644
--- a/ui/src/frontend/recording/gpu_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/gpu_settings.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Probe} from '../record_widgets';
+import {Probe} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 
 export class GpuSettings implements m.ClassComponent<RecordingSectionAttrs> {
diff --git a/ui/src/plugins/dev.perfetto.RecordTrace/index.ts b/ui/src/plugins/dev.perfetto.RecordTrace/index.ts
new file mode 100644
index 0000000..e0c5a1f
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/index.ts
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {RecordPage} from './record_page';
+import {RecordPageV2} from './record_page_v2';
+import {App} from '../../public/app';
+import {PerfettoPlugin} from '../../public/plugin';
+import {RecordingPageController} from './recordingV2/recording_page_controller';
+import {RecordingManager} from './recording_manager';
+import {PageAttrs} from '../../public/page';
+import {bindMithrilAttrs} from '../../base/mithril_utils';
+
+export default class implements PerfettoPlugin {
+  static readonly id = 'dev.perfetto.RecordTrace';
+
+  static onActivate(app: App) {
+    app.sidebar.addMenuItem({
+      section: 'navigation',
+      text: 'Record new trace',
+      href: '#!/record',
+      icon: 'fiber_smart_record',
+      sortOrder: 2,
+    });
+
+    const RECORDING_V2_FLAG = app.featureFlags.register({
+      id: 'recordingv2',
+      name: 'Recording V2',
+      description: 'Record using V2 interface',
+      defaultValue: false,
+    });
+    const useRecordingV2 = RECORDING_V2_FLAG.get();
+
+    const recMgr = new RecordingManager(app, useRecordingV2);
+    let page: m.ClassComponent<PageAttrs>;
+    if (useRecordingV2) {
+      const recCtl = new RecordingPageController(app, recMgr);
+      recCtl.initFactories();
+      page = bindMithrilAttrs(RecordPageV2, {app, recCtl, recMgr});
+    } else {
+      page = bindMithrilAttrs(RecordPage, {app, recMgr});
+    }
+    app.pages.registerPage({route: '/record', traceless: true, page});
+  }
+}
diff --git a/ui/src/frontend/recording/linux_perf_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/linux_perf_settings.ts
similarity index 96%
rename from ui/src/frontend/recording/linux_perf_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/linux_perf_settings.ts
index 7f9c6d4..a0fcf9f 100644
--- a/ui/src/frontend/recording/linux_perf_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/linux_perf_settings.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Probe, Slider, Textarea} from '../record_widgets';
+import {Probe, Slider, Textarea} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 
 const PLACEHOLDER_TEXT = `Filters for processes to profile, one per line e.g.:
diff --git a/ui/src/frontend/recording/memory_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/memory_settings.ts
similarity index 98%
rename from ui/src/frontend/recording/memory_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/memory_settings.ts
index ccfa9ef..231306f 100644
--- a/ui/src/frontend/recording/memory_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/memory_settings.ts
@@ -14,7 +14,7 @@
 
 import m from 'mithril';
 import {MeminfoCounters, VmstatCounters} from '../../protos';
-import {Dropdown, Probe, Slider, Textarea, Toggle} from '../record_widgets';
+import {Dropdown, Probe, Slider, Textarea, Toggle} from './record_widgets';
 import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
 
 class HeapSettings implements m.ClassComponent<RecordingSectionAttrs> {
diff --git a/ui/src/frontend/recording/power_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/power_settings.ts
similarity index 93%
rename from ui/src/frontend/recording/power_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/power_settings.ts
index be2f6ed..bf88217 100644
--- a/ui/src/frontend/recording/power_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/power_settings.ts
@@ -13,8 +13,8 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {globals} from '../globals';
-import {Probe, Slider} from '../record_widgets';
+import {globals} from '../../frontend/globals';
+import {Probe, Slider} from './record_widgets';
 import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
 
 export class PowerSettings implements m.ClassComponent<RecordingSectionAttrs> {
@@ -34,6 +34,7 @@
         m('span', ')'),
       ),
     ];
+    // TODO(primiano): figure out a better story for isInternalUser.
     if (globals.isInternalUser) {
       descr.push(
         m(
diff --git a/ui/src/frontend/record_config.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_config.ts
similarity index 97%
rename from ui/src/frontend/record_config.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_config.ts
index f3bf3c5..ae41d9c 100644
--- a/ui/src/frontend/record_config.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_config.ts
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {exists} from '../base/utils';
-import {getDefaultRecordingTargets, RecordingTarget} from '../common/state';
+import {exists} from '../../base/utils';
+import {getDefaultRecordingTargets, RecordingTarget} from './state';
 import {
   createEmptyRecordConfig,
   NamedRecordConfig,
   NAMED_RECORD_CONFIG_SCHEMA,
   RecordConfig,
   RECORD_CONFIG_SCHEMA,
-} from '../controller/record_config_types';
+} from './record_config_types';
 
 const LOCAL_STORAGE_RECORD_CONFIGS_KEY = 'recordConfigs';
 const LOCAL_STORAGE_AUTOSAVE_CONFIG_KEY = 'autosaveConfig';
diff --git a/ui/src/controller/record_config_types.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_config_types.ts
similarity index 100%
rename from ui/src/controller/record_config_types.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_config_types.ts
diff --git a/ui/src/controller/record_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller.ts
similarity index 95%
rename from ui/src/controller/record_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_controller.ts
index 1bfa074..8062650 100644
--- a/ui/src/controller/record_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller.ts
@@ -13,19 +13,19 @@
 // limitations under the License.
 
 import {Message, Method, rpc, RPCImplCallback} from 'protobufjs';
-import {isString} from '../base/object_utils';
-import {base64Encode} from '../base/string_utils';
-import {TRACE_SUFFIX} from '../common/constants';
-import {genTraceConfig} from '../common/recordingV2/recording_config_utils';
-import {TargetInfo} from '../common/recordingV2/recording_interfaces_v2';
+import {isString} from '../../base/object_utils';
+import {base64Encode} from '../../base/string_utils';
+import {TRACE_SUFFIX} from '../../public/trace';
+import {genTraceConfig} from './recordingV2/recording_config_utils';
+import {TargetInfo} from './recordingV2/recording_interfaces_v2';
 import {
   AdbRecordingTarget,
   isAdbTarget,
   isChromeTarget,
   isWindowsTarget,
   RecordingTarget,
-} from '../common/state';
-import {ConsumerPort, TraceConfig} from '../protos';
+} from './state';
+import {ConsumerPort, TraceConfig} from '../../protos';
 import {AdbOverWebUsb} from './adb';
 import {AdbConsumerPort} from './adb_shell_controller';
 import {AdbSocketConsumerPort} from './adb_socket_controller';
@@ -41,9 +41,9 @@
 } from './consumer_port_types';
 import {RecordConfig} from './record_config_types';
 import {Consumer, RpcConsumerPort} from './record_controller_interfaces';
-import {AppImpl} from '../core/app_impl';
 import {RecordingManager} from './recording_manager';
-import {raf} from '../core/raf_scheduler';
+import {scheduleFullRedraw} from '../../widgets/raf';
+import {App} from '../../public/app';
 
 type RPCImplMethod = Method | rpc.ServiceMethod<Message<{}>, Message<{}>>;
 
@@ -189,6 +189,7 @@
 }
 
 export class RecordController implements Consumer {
+  private app: App;
   private recMgr: RecordingManager;
   private config: RecordConfig | null = null;
   private readonly extensionPort: MessagePort;
@@ -206,7 +207,8 @@
   // char, it is the 'targetOS'
   private controllerPromises = new Map<string, Promise<RpcConsumerPort>>();
 
-  constructor(recMgr: RecordingManager, extensionPort: MessagePort) {
+  constructor(app: App, recMgr: RecordingManager, extensionPort: MessagePort) {
+    this.app = app;
     this.recMgr = recMgr;
     this.consumerPort = ConsumerPort.create(this.rpcImpl.bind(this));
     this.extensionPort = extensionPort;
@@ -219,7 +221,7 @@
   refreshOnStateChange() {
     // TODO(eseckler): Use ConsumerPort's QueryServiceState instead
     // of posting a custom extension message to retrieve the category list.
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
     if (this.state.fetchChromeCategories && !this.fetchedCategories) {
       this.fetchedCategories = true;
       if (this.state.extensionInstalled) {
@@ -320,7 +322,7 @@
       return;
     }
     const trace = this.generateTrace();
-    AppImpl.instance.openTraceFromBuffer({
+    this.app.openTraceFromBuffer({
       title: 'Recorded trace',
       buffer: trace.buffer,
       fileName: `recorded_trace${this.recordedTraceSuffix}`,
diff --git a/ui/src/controller/record_controller_interfaces.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller_interfaces.ts
similarity index 97%
rename from ui/src/controller/record_controller_interfaces.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_controller_interfaces.ts
index e9662fd..f29940a 100644
--- a/ui/src/controller/record_controller_interfaces.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller_interfaces.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TRACE_SUFFIX} from '../common/constants';
+import {TRACE_SUFFIX} from '../../public/trace';
 import {ConsumerPortResponse} from './consumer_port_types';
 
 export type ErrorCallback = (_: string) => void;
diff --git a/ui/src/controller/record_controller_jsdomtest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller_jsdomtest.ts
similarity index 99%
rename from ui/src/controller/record_controller_jsdomtest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_controller_jsdomtest.ts
index 442e4b8..1035369 100644
--- a/ui/src/controller/record_controller_jsdomtest.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_controller_jsdomtest.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertExists} from '../base/logging';
-import {TraceConfig} from '../protos';
+import {assertExists} from '../../base/logging';
+import {TraceConfig} from '../../protos';
 import {createEmptyRecordConfig} from './record_config_types';
 import {genConfigProto, toPbtxt} from './record_controller';
 
diff --git a/ui/src/frontend/record_page.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_page.ts
similarity index 92%
rename from ui/src/frontend/record_page.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_page.ts
index 6d7a579..021a5db 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_page.ts
@@ -26,35 +26,32 @@
   LoadedConfig,
   MAX_TIME,
   RecordingTarget,
-} from '../common/state';
-import {AdbOverWebUsb} from '../controller/adb';
-import {
-  RECORD_CONFIG_SCHEMA,
-  RecordConfig,
-} from '../controller/record_config_types';
-import {raf} from '../core/raf_scheduler';
-import {PageAttrs} from '../public/page';
+} from './state';
+import {AdbOverWebUsb} from './adb';
+import {RECORD_CONFIG_SCHEMA, RecordConfig} from './record_config_types';
+import {PageAttrs} from '../../public/page';
 import {
   autosaveConfigStore,
   recordConfigStore,
   recordTargetStore,
 } from './record_config';
 import {CodeSnippet} from './record_widgets';
-import {AdvancedSettings} from './recording/advanced_settings';
-import {AndroidSettings} from './recording/android_settings';
-import {ChromeSettings} from './recording/chrome_settings';
-import {CpuSettings} from './recording/cpu_settings';
-import {GpuSettings} from './recording/gpu_settings';
-import {LinuxPerfSettings} from './recording/linux_perf_settings';
-import {MemorySettings} from './recording/memory_settings';
-import {PowerSettings} from './recording/power_settings';
-import {RecordingSettings} from './recording/recording_settings';
-import {EtwSettings} from './recording/etw_settings';
-import {AppImpl} from '../core/app_impl';
-import {RecordingManager} from '../controller/recording_manager';
-import {BUCKET_NAME, GcsUploader, MIME_JSON} from '../base/gcs_uploader';
-import {showModal} from '../widgets/modal';
-import {CopyableLink} from '../widgets/copyable_link';
+import {AdvancedSettings} from './advanced_settings';
+import {AndroidSettings} from './android_settings';
+import {ChromeSettings} from './chrome_settings';
+import {CpuSettings} from './cpu_settings';
+import {GpuSettings} from './gpu_settings';
+import {LinuxPerfSettings} from './linux_perf_settings';
+import {MemorySettings} from './memory_settings';
+import {PowerSettings} from './power_settings';
+import {RecordingSettings} from './recording_settings';
+import {EtwSettings} from './etw_settings';
+import {RecordingManager} from './recording_manager';
+import {scheduleFullRedraw} from '../../widgets/raf';
+import {App} from '../../public/app';
+import {GcsUploader, BUCKET_NAME, MIME_JSON} from '../../base/gcs_uploader';
+import {showModal} from '../../widgets/modal';
+import {CopyableLink} from '../../widgets/copyable_link';
 
 export const RECORDING_SECTIONS = [
   'buffers',
@@ -155,7 +152,7 @@
 
   recMgr.setRecordingTarget(recordingTarget);
   recordTargetStore.save(target);
-  raf.scheduleFullRedraw();
+  scheduleFullRedraw();
 }
 
 function Instructions(recMgr: RecordingManager, cssClass: string) {
@@ -198,7 +195,7 @@
       disabled: loadedConfigEqual(configType, recMgr.state.lastLoadedConfig),
       onclick: () => {
         recMgr.setRecordConfig(config, configType);
-        raf.scheduleFullRedraw();
+        scheduleFullRedraw();
       },
     },
     m('i.material-icons', 'file_upload'),
@@ -244,7 +241,7 @@
                   type: 'NAMED',
                   name: item.title,
                 });
-                raf.scheduleFullRedraw();
+                scheduleFullRedraw();
               }
             },
           },
@@ -257,7 +254,7 @@
             title: 'Remove configuration',
             onclick: () => {
               recordConfigStore.delete(item.key);
-              raf.scheduleFullRedraw();
+              scheduleFullRedraw();
             },
           },
           m('i.material-icons', 'delete'),
@@ -292,7 +289,7 @@
         placeholder: 'Title for config',
         oninput() {
           ConfigTitleState.setTitle(this.value);
-          raf.scheduleFullRedraw();
+          scheduleFullRedraw();
         },
       }),
       m(
@@ -308,7 +305,7 @@
               recMgr.state.recordConfig,
               ConfigTitleState.getTitle(),
             );
-            raf.scheduleFullRedraw();
+            scheduleFullRedraw();
             ConfigTitleState.clearTitle();
           },
         },
@@ -326,7 +323,7 @@
               )
             ) {
               recMgr.clearRecordConfig();
-              raf.scheduleFullRedraw();
+              scheduleFullRedraw();
             }
           },
         },
@@ -573,7 +570,7 @@
 
 function onStartRecordingPressed(recMgr: RecordingManager) {
   location.href = '#!/record/instructions';
-  raf.scheduleFullRedraw();
+  scheduleFullRedraw();
   autosaveConfigStore.save(recMgr.state.recordConfig);
 
   const target = recMgr.state.recordingTarget;
@@ -582,7 +579,7 @@
     isChromeTarget(target) ||
     isWindowsTarget(target)
   ) {
-    AppImpl.instance.analytics.logEvent(
+    recMgr.app.analytics.logEvent(
       'Record Trace',
       `Record trace (${target.os})`,
     );
@@ -742,7 +739,7 @@
     '.record-menu',
     {
       class: recInProgress ? 'disabled' : '',
-      onclick: () => raf.scheduleFullRedraw(),
+      onclick: () => scheduleFullRedraw(),
     },
     m('header', 'Trace config'),
     m(
@@ -789,20 +786,29 @@
   return routePage === section ? '.active' : '';
 }
 
-export class RecordPage implements m.ClassComponent<PageAttrs> {
-  private readonly recMgr = RecordingManager.instance;
+export interface RecordPageAttrs extends PageAttrs {
+  app: App;
+  recMgr: RecordingManager;
+}
+
+export class RecordPage implements m.ClassComponent<RecordPageAttrs> {
+  private readonly recMgr: RecordingManager;
   private lastSubpage: string | undefined = undefined;
 
-  oninit({attrs}: m.CVnode<PageAttrs>) {
+  constructor({attrs}: m.CVnode<RecordPageAttrs>) {
+    this.recMgr = attrs.recMgr;
+  }
+
+  oninit({attrs}: m.CVnode<RecordPageAttrs>) {
     this.lastSubpage = attrs.subpage;
     if (attrs.subpage !== undefined && attrs.subpage.startsWith('/share/')) {
       const hash = attrs.subpage.substring(7);
       loadRecordConfig(this.recMgr, hash);
-      AppImpl.instance.navigate('#!/record/instructions');
+      attrs.app.navigate('#!/record/instructions');
     }
   }
 
-  view({attrs}: m.CVnode<PageAttrs>) {
+  view({attrs}: m.CVnode<RecordPageAttrs>) {
     if (attrs.subpage !== this.lastSubpage) {
       this.lastSubpage = attrs.subpage;
       // TODO(primiano): this is a hack necesasry to retrigger the generation of
diff --git a/ui/src/frontend/record_page_v2.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_page_v2.ts
similarity index 80%
rename from ui/src/frontend/record_page_v2.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_page_v2.ts
index e0faccd..3559332 100644
--- a/ui/src/frontend/record_page_v2.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_page_v2.ts
@@ -14,24 +14,20 @@
 
 import m from 'mithril';
 import {Attributes} from 'mithril';
-import {assertExists} from '../base/logging';
-import {RecordingConfigUtils} from '../common/recordingV2/recording_config_utils';
+import {assertExists} from '../../base/logging';
+import {RecordingConfigUtils} from './recordingV2/recording_config_utils';
 import {
   ChromeTargetInfo,
   RecordingTargetV2,
   TargetInfo,
-} from '../common/recordingV2/recording_interfaces_v2';
+} from './recordingV2/recording_interfaces_v2';
 import {
   RecordingPageController,
   RecordingState,
-} from '../common/recordingV2/recording_page_controller';
-import {
-  EXTENSION_NAME,
-  EXTENSION_URL,
-} from '../common/recordingV2/recording_utils';
-import {targetFactoryRegistry} from '../common/recordingV2/target_factory_registry';
-import {raf} from '../core/raf_scheduler';
-import {PageAttrs} from '../public/page';
+} from './recordingV2/recording_page_controller';
+import {EXTENSION_NAME, EXTENSION_URL} from './recordingV2/recording_utils';
+import {targetFactoryRegistry} from './recordingV2/target_factory_registry';
+import {PageAttrs} from '../../public/page';
 import {recordConfigStore} from './record_config';
 import {
   Configurations,
@@ -41,34 +37,29 @@
   uploadRecordingConfig,
 } from './record_page';
 import {CodeSnippet} from './record_widgets';
-import {AdvancedSettings} from './recording/advanced_settings';
-import {AndroidSettings} from './recording/android_settings';
-import {ChromeSettings} from './recording/chrome_settings';
-import {CpuSettings} from './recording/cpu_settings';
-import {EtwSettings} from './recording/etw_settings';
-import {GpuSettings} from './recording/gpu_settings';
-import {LinuxPerfSettings} from './recording/linux_perf_settings';
-import {MemorySettings} from './recording/memory_settings';
-import {PowerSettings} from './recording/power_settings';
-import {RecordingSettings} from './recording/recording_settings';
-import {FORCE_RESET_MESSAGE} from './recording/recording_ui_utils';
-import {showAddNewTargetModal} from './recording/reset_target_modal';
-import {RecordingManager} from '../controller/recording_manager';
-import {RecordConfig} from '../controller/record_config_types';
-import {AppImpl} from '../core/app_impl';
+import {AdvancedSettings} from './advanced_settings';
+import {AndroidSettings} from './android_settings';
+import {ChromeSettings} from './chrome_settings';
+import {CpuSettings} from './cpu_settings';
+import {EtwSettings} from './etw_settings';
+import {GpuSettings} from './gpu_settings';
+import {LinuxPerfSettings} from './linux_perf_settings';
+import {MemorySettings} from './memory_settings';
+import {PowerSettings} from './power_settings';
+import {RecordingSettings} from './recording_settings';
+import {FORCE_RESET_MESSAGE} from './recording_ui_utils';
+import {showAddNewTargetModal} from './reset_target_modal';
+import {RecordingManager} from './recording_manager';
+import {RecordConfig} from './record_config_types';
+import {App} from '../../public/app';
+import {scheduleFullRedraw} from '../../widgets/raf';
 
 const START_RECORDING_MESSAGE = 'Start Recording';
 
 // TODO(primiano): this is needs to be rewritten, but then i'm going to rewrite
 // the whole record_page_v2 so not worth cleaning up now.
-let _controller: RecordingPageController;
-function controller(): RecordingPageController {
-  if (_controller === undefined) {
-    _controller = new RecordingPageController(RecordingManager.instance);
-  }
-  return _controller;
-}
-const recordConfigUtils = new RecordingConfigUtils();
+let controller: RecordingPageController;
+let recordConfigUtils: RecordingConfigUtils;
 
 // Options for displaying a target selection menu.
 export interface TargetSelectionOptions {
@@ -107,13 +98,13 @@
 
 function RecordingPlatformSelection() {
   // Don't show the platform selector while we are recording a trace.
-  if (controller().getState() >= RecordingState.RECORDING) return undefined;
+  if (controller.getState() >= RecordingState.RECORDING) return undefined;
 
   return m(
     '.target',
     m(
       '.chip',
-      {onclick: () => showAddNewTargetModal(controller())},
+      {onclick: () => showAddNewTargetModal(controller)},
       m('button', 'Add new recording target'),
       m('i.material-icons', 'add'),
     ),
@@ -122,13 +113,13 @@
 }
 
 export function targetSelection(): m.Vnode | undefined {
-  if (!controller().shouldShowTargetSelection()) {
+  if (!controller.shouldShowTargetSelection()) {
     return undefined;
   }
 
   const targets: RecordingTargetV2[] = targetFactoryRegistry.listTargets();
   const targetNames = [];
-  const targetInfo = controller().getTargetInfo();
+  const targetInfo = controller.getTargetInfo();
   if (!targetInfo) {
     targetNames.push(m('option', 'PLEASE_SELECT_TARGET'));
   }
@@ -150,7 +141,7 @@
       {
         selectedIndex,
         onchange: (e: Event) => {
-          controller().onTargetSelection((e.target as HTMLSelectElement).value);
+          controller.onTargetSelection((e.target as HTMLSelectElement).value);
         },
         onupdate: (select) => {
           // Work around mithril bug
@@ -179,11 +170,11 @@
 }
 
 function Instructions(recCfg: RecordConfig, cssClass: string) {
-  if (controller().getState() < RecordingState.TARGET_SELECTED) {
+  if (controller.getState() < RecordingState.TARGET_SELECTED) {
     return undefined;
   }
   // We will have a valid target at this step because we checked the state.
-  const targetInfo = assertExists(controller().getTargetInfo());
+  const targetInfo = assertExists(controller.getTargetInfo());
 
   return m(
     `.record-section.instructions${cssClass}`,
@@ -203,13 +194,13 @@
 
 function BufferUsageProgressBar() {
   // Show the Buffer Usage bar only after we start recording a trace.
-  if (controller().getState() !== RecordingState.RECORDING) {
+  if (controller.getState() !== RecordingState.RECORDING) {
     return undefined;
   }
 
-  controller().fetchBufferUsage();
+  controller.fetchBufferUsage();
 
-  const bufferUsage = controller().getBufferUsagePercentage();
+  const bufferUsage = controller.getBufferUsagePercentage();
   // Buffer usage is not available yet on Android.
   if (bufferUsage === 0) return undefined;
 
@@ -221,11 +212,11 @@
 }
 
 function RecordingNotes(recCfg: RecordConfig) {
-  if (controller().getState() !== RecordingState.TARGET_INFO_DISPLAYED) {
+  if (controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED) {
     return undefined;
   }
   // We will have a valid target at this step because we checked the state.
-  const targetInfo = assertExists(controller().getTargetInfo());
+  const targetInfo = assertExists(controller.getTargetInfo());
 
   const linuxUrl = 'https://perfetto.dev/docs/quickstart/linux-tracing';
   const cmdlineUrl =
@@ -320,7 +311,7 @@
 function RecordingSnippet(recCfg: RecordConfig, targetInfo: TargetInfo) {
   // We don't need commands to start tracing on chrome
   if (isChromeTargetInfo(targetInfo)) {
-    if (controller().getState() > RecordingState.AUTH_P2) {
+    if (controller.getState() > RecordingState.AUTH_P2) {
       // If the UI has started tracing, don't display a message guiding the user
       // to start recording.
       return undefined;
@@ -372,14 +363,14 @@
 
 function RecordingButton(recCfg: RecordConfig) {
   if (
-    controller().getState() !== RecordingState.TARGET_INFO_DISPLAYED ||
-    !controller().canCreateTracingSession()
+    controller.getState() !== RecordingState.TARGET_INFO_DISPLAYED ||
+    !controller.canCreateTracingSession()
   ) {
     return undefined;
   }
 
   // We know we have a target because we checked the state.
-  const targetInfo = assertExists(controller().getTargetInfo());
+  const targetInfo = assertExists(controller.getTargetInfo());
   const hasDataSources = recordConfigUtils.fetchLatestRecordCommand(
     recCfg,
     targetInfo,
@@ -394,7 +385,7 @@
       'button',
       {
         class: 'selected',
-        onclick: () => controller().onStartRecordingPressed(),
+        onclick: () => controller.onStartRecordingPressed(),
       },
       START_RECORDING_MESSAGE,
     ),
@@ -403,21 +394,17 @@
 
 function StopCancelButtons() {
   // Show the Stop/Cancel buttons only while we are recording a trace.
-  if (!controller().shouldShowStopCancelButtons()) {
+  if (!controller.shouldShowStopCancelButtons()) {
     return undefined;
   }
 
   const stop = m(
     `button.selected`,
-    {onclick: () => controller().onStop()},
+    {onclick: () => controller.onStop()},
     'Stop',
   );
 
-  const cancel = m(
-    `button`,
-    {onclick: () => controller().onCancel()},
-    'Cancel',
-  );
+  const cancel = m(`button`, {onclick: () => controller.onCancel()}, 'Cancel');
 
   return [stop, cancel];
 }
@@ -507,7 +494,7 @@
 
   // We only display the probes when we have a valid target, so it's not
   // possible for the target to be undefined here.
-  const targetType = assertExists(controller().getTargetInfo()).targetType;
+  const targetType = assertExists(controller.getTargetInfo()).targetType;
   const probes = [];
   if (targetType === 'LINUX') {
     probes.push(cpuProbe, powerProbe, memoryProbe, chromeProbe, advancedProbe);
@@ -532,10 +519,10 @@
     '.record-menu',
     {
       class:
-        controller().getState() > RecordingState.TARGET_INFO_DISPLAYED
+        controller.getState() > RecordingState.TARGET_INFO_DISPLAYED
           ? 'disabled'
           : '',
-      onclick: () => raf.scheduleFullRedraw(),
+      onclick: () => scheduleFullRedraw(),
     },
     m('header', 'Trace config'),
     m(
@@ -581,10 +568,10 @@
 function getRecordContainer(recMgr: RecordingManager, subpage?: string) {
   const recCfg = recMgr.state.recordConfig;
   const components: m.Children[] = [RecordHeader(recMgr)];
-  if (controller().getState() === RecordingState.NO_TARGET) {
+  if (controller.getState() === RecordingState.NO_TARGET) {
     components.push(m('.full-centered', 'Please connect a valid target.'));
     return m('.record-container', components);
-  } else if (controller().getState() <= RecordingState.ASK_TO_FORCE_P1) {
+  } else if (controller.getState() <= RecordingState.ASK_TO_FORCE_P1) {
     components.push(
       m(
         '.full-centered',
@@ -594,13 +581,13 @@
       ),
     );
     return m('.record-container', components);
-  } else if (controller().getState() === RecordingState.AUTH_P1) {
+  } else if (controller.getState() === RecordingState.AUTH_P1) {
     components.push(
       m('.full-centered', 'Please allow USB debugging on the device.'),
     );
     return m('.record-container', components);
   } else if (
-    controller().getState() === RecordingState.WAITING_FOR_TRACE_DISPLAY
+    controller.getState() === RecordingState.WAITING_FOR_TRACE_DISPLAY
   ) {
     components.push(
       m('.full-centered', 'Waiting for the trace to be collected.'),
@@ -642,7 +629,7 @@
   for (const [section, component] of settingsSections.entries()) {
     pages.push(
       m(component, {
-        dataSources: controller().getTargetInfo()?.dataSources || [],
+        dataSources: controller.getTargetInfo()?.dataSources || [],
         cssClass: maybeGetActiveCss(routePage, section),
         recState: recMgr.state,
       }),
@@ -653,34 +640,43 @@
   return m('.record-container', components);
 }
 
-export class RecordPageV2 implements m.ClassComponent<PageAttrs> {
-  private readonly recMgr = RecordingManager.instance;
+export interface RecordPageV2Attrs extends PageAttrs {
+  app: App;
+  recCtl: RecordingPageController;
+  recMgr: RecordingManager;
+}
+
+export class RecordPageV2 implements m.ClassComponent<RecordPageV2Attrs> {
   private lastSubpage: string | undefined = undefined;
 
-  oninit({attrs}: m.CVnode<PageAttrs>) {
-    controller().initFactories();
+  constructor({attrs}: m.CVnode<RecordPageV2Attrs>) {
+    controller ??= attrs.recCtl;
+    recordConfigUtils ??= new RecordingConfigUtils();
+  }
+
+  oninit({attrs}: m.CVnode<RecordPageV2Attrs>) {
     this.lastSubpage = attrs.subpage;
     if (attrs.subpage !== undefined && attrs.subpage.startsWith('/share/')) {
       const hash = attrs.subpage.substring(7);
-      loadRecordConfig(this.recMgr, hash);
-      AppImpl.instance.navigate('#!/record/instructions');
+      loadRecordConfig(attrs.recMgr, hash);
+      attrs.app.navigate('#!/record/instructions');
     }
   }
 
-  view({attrs}: m.CVnode<PageAttrs>) {
+  view({attrs}: m.CVnode<RecordPageV2Attrs>) {
     if (attrs.subpage !== this.lastSubpage) {
       this.lastSubpage = attrs.subpage;
       // TODO(primiano): this is a hack necesasry to retrigger the generation of
       // the record cmdline. Refactor this code once record v1 vs v2 is gone.
-      this.recMgr.setRecordConfig(this.recMgr.state.recordConfig);
+      attrs.recMgr.setRecordConfig(attrs.recMgr.state.recordConfig);
     }
 
     return m(
       '.record-page',
-      controller().getState() > RecordingState.TARGET_INFO_DISPLAYED
+      controller.getState() > RecordingState.TARGET_INFO_DISPLAYED
         ? m('.hider')
         : [],
-      getRecordContainer(this.recMgr, attrs.subpage),
+      getRecordContainer(attrs.recMgr, attrs.subpage),
     );
   }
 }
diff --git a/ui/src/frontend/record_widgets.ts b/ui/src/plugins/dev.perfetto.RecordTrace/record_widgets.ts
similarity index 96%
rename from ui/src/frontend/record_widgets.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/record_widgets.ts
index 90f3c3d..325237b 100644
--- a/ui/src/frontend/record_widgets.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/record_widgets.ts
@@ -13,11 +13,11 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {copyToClipboard} from '../base/clipboard';
-import {assertExists} from '../base/logging';
-import {RecordConfig} from '../controller/record_config_types';
-import {raf} from '../core/raf_scheduler';
-import {assetSrc} from '../base/assets';
+import {copyToClipboard} from '../../base/clipboard';
+import {assertExists} from '../../base/logging';
+import {RecordConfig} from './record_config_types';
+import {assetSrc} from '../../base/assets';
+import {scheduleFullRedraw} from '../../widgets/raf';
 
 export declare type Setter<T> = (cfg: RecordConfig, val: T) => void;
 export declare type Getter<T> = (cfg: RecordConfig) => T;
@@ -63,7 +63,7 @@
   view({attrs, children}: m.CVnode<ProbeAttrs>) {
     const onToggle = (enabled: boolean) => {
       attrs.setEnabled(attrs.recCfg, enabled);
-      raf.scheduleFullRedraw();
+      scheduleFullRedraw();
     };
 
     const enabled = attrs.isEnabled(attrs.recCfg);
@@ -130,7 +130,7 @@
   view({attrs}: m.CVnode<ToggleAttrs>) {
     const onToggle = (enabled: boolean) => {
       attrs.setEnabled(attrs.recCfg, enabled);
-      raf.scheduleFullRedraw();
+      scheduleFullRedraw();
     };
 
     const enabled = attrs.isEnabled(attrs.recCfg);
@@ -175,7 +175,7 @@
 export class Slider implements m.ClassComponent<SliderAttrs> {
   onValueChange(attrs: SliderAttrs, newVal: number) {
     attrs.set(attrs.recCfg, newVal);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   onTimeValueChange(attrs: SliderAttrs, hms: string) {
@@ -276,7 +276,7 @@
       selKeys.push(item.value);
     }
     attrs.set(attrs.recCfg, selKeys);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   view({attrs}: m.CVnode<DropdownAttrs>) {
@@ -326,7 +326,7 @@
 export class Textarea implements m.ClassComponent<TextareaAttrs> {
   onChange(attrs: TextareaAttrs, dom: HTMLTextAreaElement) {
     attrs.set(attrs.recCfg, dom.value);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   view({attrs}: m.CVnode<TextareaAttrs>) {
@@ -400,7 +400,7 @@
     if (!enabled && index !== -1) {
       values.splice(index, 1);
     }
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   view({attrs}: m.CVnode<CategoriesCheckboxListParams>) {
diff --git a/ui/src/common/recordingV2/adb_connection_impl.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_impl.ts
similarity index 94%
rename from ui/src/common/recordingV2/adb_connection_impl.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_impl.ts
index 99ef224..33e0dc1 100644
--- a/ui/src/common/recordingV2/adb_connection_impl.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_impl.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer} from '../../base/deferred';
-import {ArrayBufferBuilder} from '../../base/array_buffer_builder';
+import {defer} from '../../../base/deferred';
+import {ArrayBufferBuilder} from '../../../base/array_buffer_builder';
 import {AdbFileHandler} from './adb_file_handler';
 import {
   AdbConnection,
@@ -21,7 +21,7 @@
   OnDisconnectCallback,
   OnMessageCallback,
 } from './recording_interfaces_v2';
-import {utf8Decode} from '../../base/string_utils';
+import {utf8Decode} from '../../../base/string_utils';
 
 export abstract class AdbConnectionImpl implements AdbConnection {
   // onStatus and onDisconnect are set to callbacks passed from the caller.
diff --git a/ui/src/common/recordingV2/adb_connection_over_websocket.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_websocket.ts
similarity index 98%
rename from ui/src/common/recordingV2/adb_connection_over_websocket.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_websocket.ts
index 160b257..9c9d139 100644
--- a/ui/src/common/recordingV2/adb_connection_over_websocket.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_websocket.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer, Deferred} from '../../base/deferred';
-import {utf8Decode} from '../../base/string_utils';
+import {defer, Deferred} from '../../../base/deferred';
+import {utf8Decode} from '../../../base/string_utils';
 import {AdbConnectionImpl} from './adb_connection_impl';
 import {RecordingError} from './recording_error_handling';
 import {
diff --git a/ui/src/common/recordingV2/adb_connection_over_webusb.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_webusb.ts
similarity index 98%
rename from ui/src/common/recordingV2/adb_connection_over_webusb.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_webusb.ts
index 713d8b3..715d366 100644
--- a/ui/src/common/recordingV2/adb_connection_over_webusb.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_connection_over_webusb.ts
@@ -12,11 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer, Deferred} from '../../base/deferred';
-import {assertExists, assertFalse, assertTrue} from '../../base/logging';
-import {isString} from '../../base/object_utils';
-import {utf8Decode, utf8Encode} from '../../base/string_utils';
-import {CmdType} from '../../controller/adb_interfaces';
+import {defer, Deferred} from '../../../base/deferred';
+import {assertExists, assertFalse, assertTrue} from '../../../base/logging';
+import {isString} from '../../../base/object_utils';
+import {utf8Decode, utf8Encode} from '../../../base/string_utils';
+import {CmdType} from '../adb_interfaces';
 import {AdbConnectionImpl} from './adb_connection_impl';
 import {AdbKeyManager, maybeStoreKey} from './auth/adb_key_manager';
 import {RecordingError, wrapRecordingError} from './recording_error_handling';
diff --git a/ui/src/common/recordingV2/adb_file_handler.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_file_handler.ts
similarity index 94%
rename from ui/src/common/recordingV2/adb_file_handler.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_file_handler.ts
index 1016fe7..078726f 100644
--- a/ui/src/common/recordingV2/adb_file_handler.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/adb_file_handler.ts
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer, Deferred} from '../../base/deferred';
-import {assertFalse} from '../../base/logging';
-import {ArrayBufferBuilder} from '../../base/array_buffer_builder';
+import {defer, Deferred} from '../../../base/deferred';
+import {assertFalse} from '../../../base/logging';
+import {ArrayBufferBuilder} from '../../../base/array_buffer_builder';
 import {RecordingError} from './recording_error_handling';
 import {ByteStream} from './recording_interfaces_v2';
 import {
   BINARY_PUSH_FAILURE,
   BINARY_PUSH_UNKNOWN_RESPONSE,
 } from './recording_utils';
-import {utf8Decode} from '../../base/string_utils';
+import {utf8Decode} from '../../../base/string_utils';
 
 // https://cs.android.com/android/platform/superproject/+/main:packages/
 // modules/adb/file_sync_protocol.h;l=144
diff --git a/ui/src/common/recordingV2/auth/adb_auth.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_auth.ts
similarity index 97%
rename from ui/src/common/recordingV2/auth/adb_auth.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_auth.ts
index aec8752..7ed275e 100644
--- a/ui/src/common/recordingV2/auth/adb_auth.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_auth.ts
@@ -13,12 +13,12 @@
 // limitations under the License.
 
 import {BigInteger, RSAKey} from 'jsbn-rsa';
-import {assertExists, assertTrue} from '../../../base/logging';
+import {assertExists, assertTrue} from '../../../../base/logging';
 import {
   base64Decode,
   base64Encode,
   hexEncode,
-} from '../../../base/string_utils';
+} from '../../../../base/string_utils';
 import {RecordingError} from '../recording_error_handling';
 
 const WORD_SIZE = 4;
diff --git a/ui/src/common/recordingV2/auth/adb_key_manager.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_key_manager.ts
similarity index 98%
rename from ui/src/common/recordingV2/auth/adb_key_manager.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_key_manager.ts
index 53e233f..0ce297b 100644
--- a/ui/src/common/recordingV2/auth/adb_key_manager.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/adb_key_manager.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assetSrc} from '../../../base/assets';
+import {assetSrc} from '../../../../base/assets';
 import {AdbKey} from './adb_auth';
 
 function isPasswordCredential(
diff --git a/ui/src/common/recordingV2/auth/credentials_interfaces.d.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/credentials_interfaces.d.ts
similarity index 100%
rename from ui/src/common/recordingV2/auth/credentials_interfaces.d.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/auth/credentials_interfaces.d.ts
diff --git a/ui/src/common/recordingV2/chrome_traced_tracing_session.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/chrome_traced_tracing_session.ts
similarity index 95%
rename from ui/src/common/recordingV2/chrome_traced_tracing_session.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/chrome_traced_tracing_session.ts
index f8ecd03..9461190 100644
--- a/ui/src/common/recordingV2/chrome_traced_tracing_session.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/chrome_traced_tracing_session.ts
@@ -12,28 +12,28 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer, Deferred} from '../../base/deferred';
-import {assertExists, assertTrue} from '../../base/logging';
-import {binaryDecode, binaryEncode} from '../../base/string_utils';
+import {defer, Deferred} from '../../../base/deferred';
+import {assertExists, assertTrue} from '../../../base/logging';
+import {binaryDecode, binaryEncode} from '../../../base/string_utils';
 import {
   ChromeExtensionMessage,
   isChromeExtensionError,
   isChromeExtensionStatus,
   isGetCategoriesResponse,
-} from '../../controller/chrome_proxy_record_controller';
+} from '../chrome_proxy_record_controller';
 import {
   isDisableTracingResponse,
   isEnableTracingResponse,
   isFreeBuffersResponse,
   isGetTraceStatsResponse,
   isReadBuffersResponse,
-} from '../../controller/consumer_port_types';
+} from '../consumer_port_types';
 import {
   EnableTracingRequest,
   IBufferStats,
   ISlice,
   TraceConfig,
-} from '../../protos';
+} from '../../../protos';
 import {RecordingError} from './recording_error_handling';
 import {
   TracingSession,
diff --git a/ui/src/common/recordingV2/host_os_byte_stream.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/host_os_byte_stream.ts
similarity index 97%
rename from ui/src/common/recordingV2/host_os_byte_stream.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/host_os_byte_stream.ts
index 3c43630..a03b791 100644
--- a/ui/src/common/recordingV2/host_os_byte_stream.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/host_os_byte_stream.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {defer} from '../../base/deferred';
+import {defer} from '../../../base/deferred';
 import {
   ByteStream,
   OnStreamCloseCallback,
diff --git a/ui/src/common/recordingV2/recording_config_utils.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils.ts
similarity index 98%
rename from ui/src/common/recordingV2/recording_config_utils.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils.ts
index bc3262e..e4eca50 100644
--- a/ui/src/common/recordingV2/recording_config_utils.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils.ts
@@ -12,10 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {isString} from '../../base/object_utils';
-import {base64Encode} from '../../base/string_utils';
-import {exists} from '../../base/utils';
-import {RecordConfig} from '../../controller/record_config_types';
+import {isString} from '../../../base/object_utils';
+import {base64Encode} from '../../../base/string_utils';
+import {exists} from '../../../base/utils';
+import {RecordConfig} from '../record_config_types';
 import {
   AndroidLogConfig,
   AndroidLogId,
@@ -41,7 +41,7 @@
   TraceConfig,
   TrackEventConfig,
   VmstatCounters,
-} from '../../protos';
+} from '../../../protos';
 import {TargetInfo} from './recording_interfaces_v2';
 import PerfClock = PerfEvents.PerfClock;
 import Timebase = PerfEvents.Timebase;
@@ -463,7 +463,7 @@
 
     if (uiCfg.androidStatsdPushedAtoms.length > 0) {
       ds.config.statsdTracingConfig.pushAtomId =
-        uiCfg.androidStatsdPushedAtoms.map((atom) => atom as any as AtomId);
+        uiCfg.androidStatsdPushedAtoms.map((atom) => atom as unknown as AtomId);
     }
 
     const needPulledAtomConfig =
@@ -471,7 +471,7 @@
       uiCfg.androidStatsdPulledAtoms.length > 0;
 
     if (needPulledAtomConfig) {
-      let pullAtomConfig = new StatsdPullAtomConfig();
+      const pullAtomConfig = new StatsdPullAtomConfig();
       if (uiCfg.androidStatsdRawPulledAtoms.length > 0) {
         for (const line of uiCfg.androidStatsdRawPulledAtoms.split('\n')) {
           if (line.trim().length > 0) {
@@ -479,8 +479,9 @@
           }
         }
       }
-      pullAtomConfig.pullAtomId =
-        uiCfg.androidStatsdPulledAtoms.map((atom) => atom as any as AtomId);
+      pullAtomConfig.pullAtomId = uiCfg.androidStatsdPulledAtoms.map(
+        (atom) => atom as unknown as AtomId,
+      );
       pullAtomConfig.pullFrequencyMs =
         uiCfg.androidStatsdPulledAtomPullFrequencyMs;
       if (uiCfg.androidStatsdPulledAtomPackages.length > 0) {
diff --git a/ui/src/common/recordingV2/recording_config_utils_unittest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils_unittest.ts
similarity index 96%
rename from ui/src/common/recordingV2/recording_config_utils_unittest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils_unittest.ts
index 67ac112..dd96a69 100644
--- a/ui/src/common/recordingV2/recording_config_utils_unittest.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_config_utils_unittest.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {createEmptyRecordConfig} from '../../controller/record_config_types';
+import {createEmptyRecordConfig} from '../record_config_types';
 import {genTraceConfig} from './recording_config_utils';
 import {AndroidTargetInfo} from './recording_interfaces_v2';
 
diff --git a/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_error_handling.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_error_handling.ts
new file mode 100644
index 0000000..ba86e65
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_error_handling.ts
@@ -0,0 +1,263 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {getErrorMessage} from '../../../base/errors';
+import {showModal} from '../../../widgets/modal';
+import {OnMessageCallback} from './recording_interfaces_v2';
+import {
+  ALLOW_USB_DEBUGGING,
+  BINARY_PUSH_FAILURE,
+  BINARY_PUSH_UNKNOWN_RESPONSE,
+  EXTENSION_NOT_INSTALLED,
+  EXTENSION_URL,
+  NO_DEVICE_SELECTED,
+  PARSING_UNABLE_TO_DECODE_METHOD,
+  PARSING_UNKNWON_REQUEST_ID,
+  PARSING_UNRECOGNIZED_MESSAGE,
+  PARSING_UNRECOGNIZED_PORT,
+  WEBSOCKET_UNABLE_TO_CONNECT,
+} from './recording_utils';
+
+// The pattern for handling recording error can have the following nesting in
+// case of errors:
+// A. wrapRecordingError -> wraps a promise
+// B. onFailure -> has user defined logic and calls showRecordingModal
+// C. showRecordingModal -> shows UX for a given error; this is not called
+//    directly by wrapRecordingError, because we want the caller (such as the
+//    UI) to dictate the UX
+
+// This method takes a promise and a callback to be execute in case the promise
+// fails. It then awaits the promise and executes the callback in case of
+// failure. In the recording code it is used to wrap:
+// 1. Acessing the WebUSB API.
+// 2. Methods returning promises which can be rejected. For instance:
+// a) When the user clicks 'Add a new device' but then doesn't select a valid
+//    device.
+// b) When the user starts a tracing session, but cancels it before they
+//    authorize the session on the device.
+export async function wrapRecordingError<T>(
+  promise: Promise<T>,
+  onFailure: OnMessageCallback,
+): Promise<T | undefined> {
+  try {
+    return await promise;
+  } catch (e) {
+    // Sometimes the message is wrapped in an Error object, sometimes not, so
+    // we make sure we transform it into a string.
+    const errorMessage = getErrorMessage(e);
+    onFailure(errorMessage);
+    return undefined;
+  }
+}
+
+// Shows a modal for every known type of error which can arise during recording.
+// In this way, errors occuring at different levels of the recording process
+// can be handled in a central location.
+export function showRecordingModal(message: string): void {
+  if (
+    [
+      'Unable to claim interface.',
+      'The specified endpoint is not part of a claimed and selected ' +
+        'alternate interface.',
+      // thrown when calling the 'reset' method on a WebUSB device.
+      'Unable to reset the device.',
+    ].some((partOfMessage) => message.includes(partOfMessage))
+  ) {
+    showWebUSBErrorV2();
+  } else if (
+    [
+      'A transfer error has occurred.',
+      'The device was disconnected.',
+      'The transfer was cancelled.',
+    ].some((partOfMessage) => message.includes(partOfMessage)) ||
+    isDeviceDisconnectedError(message)
+  ) {
+    showConnectionLostError();
+  } else if (message === ALLOW_USB_DEBUGGING) {
+    showAllowUSBDebugging();
+  } else if (
+    isMessageComposedOf(message, [
+      BINARY_PUSH_FAILURE,
+      BINARY_PUSH_UNKNOWN_RESPONSE,
+    ])
+  ) {
+    showFailedToPushBinary(message.substring(message.indexOf(':') + 1));
+  } else if (message === NO_DEVICE_SELECTED) {
+    showNoDeviceSelected();
+  } else if (WEBSOCKET_UNABLE_TO_CONNECT === message) {
+    showWebsocketConnectionIssue(message);
+  } else if (message === EXTENSION_NOT_INSTALLED) {
+    showExtensionNotInstalled();
+  } else if (
+    isMessageComposedOf(message, [
+      PARSING_UNKNWON_REQUEST_ID,
+      PARSING_UNABLE_TO_DECODE_METHOD,
+      PARSING_UNRECOGNIZED_PORT,
+      PARSING_UNRECOGNIZED_MESSAGE,
+    ])
+  ) {
+    showIssueParsingTheTracedResponse(message);
+  } else {
+    throw new Error(`${message}`);
+  }
+}
+
+function isDeviceDisconnectedError(message: string) {
+  return (
+    message.includes('Device with serial') &&
+    message.includes('was disconnected.')
+  );
+}
+
+function isMessageComposedOf(message: string, issues: string[]) {
+  for (const issue of issues) {
+    if (message.includes(issue)) {
+      return true;
+    }
+  }
+  return false;
+}
+
+// Exception thrown by the Recording logic.
+export class RecordingError extends Error {}
+
+function showWebUSBErrorV2() {
+  showModal({
+    title: 'A WebUSB error occurred',
+    content: m(
+      'div',
+      m(
+        'span',
+        `Is adb already running on the host? Run this command and
+      try again.`,
+      ),
+      m('br'),
+      m('.modal-bash', '> adb kill-server'),
+      m('br'),
+      // The statement below covers the following edge case:
+      // 1. 'adb server' is running on the device.
+      // 2. The user selects the new Android target, so we try to fetch the
+      // OS version and do QSS.
+      // 3. The error modal is shown.
+      // 4. The user runs 'adb kill-server'.
+      // At this point we don't have a trigger to try fetching the OS version
+      // + QSS again. Therefore, the user will need to refresh the page.
+      m(
+        'span',
+        "If after running 'adb kill-server', you don't see " +
+          "a 'Start Recording' button on the page and you don't see " +
+          "'Allow USB debugging' on the device, " +
+          'you will need to reload this page.',
+      ),
+      m('br'),
+      m('br'),
+      m('span', 'For details see '),
+      m('a', {href: 'http://b/159048331', target: '_blank'}, 'b/159048331'),
+    ),
+  });
+}
+
+function showConnectionLostError(): void {
+  showModal({
+    title: 'Connection with the ADB device lost',
+    content: m(
+      'div',
+      m('span', `Please connect the device again to restart the recording.`),
+      m('br'),
+    ),
+  });
+}
+
+function showAllowUSBDebugging(): void {
+  showModal({
+    title: 'Could not connect to the device',
+    content: m(
+      'div',
+      m('span', 'Please allow USB debugging on the device.'),
+      m('br'),
+    ),
+  });
+}
+
+function showNoDeviceSelected(): void {
+  showModal({
+    title: 'No device was selected for recording',
+    content: m(
+      'div',
+      m(
+        'span',
+        `If you want to connect to an ADB device,
+           please select it from the list.`,
+      ),
+      m('br'),
+    ),
+  });
+}
+
+function showExtensionNotInstalled(): void {
+  showModal({
+    title: 'Perfetto Chrome extension not installed',
+    content: m(
+      'div',
+      m(
+        '.note',
+        `To trace Chrome from the Perfetto UI, you need to install our `,
+        m('a', {href: EXTENSION_URL, target: '_blank'}, 'Chrome extension'),
+        ' and then reload this page.',
+      ),
+      m('br'),
+    ),
+  });
+}
+
+function showIssueParsingTheTracedResponse(message: string): void {
+  showModal({
+    title:
+      'A problem was encountered while connecting to' +
+      ' the Perfetto tracing service',
+    content: m('div', m('span', message), m('br')),
+  });
+}
+
+function showFailedToPushBinary(message: string): void {
+  showModal({
+    title: 'Failed to push a binary to the device',
+    content: m(
+      'div',
+      m(
+        'span',
+        'This can happen if your Android device has an OS version lower ' +
+          'than Q. Perfetto tried to push the latest version of its ' +
+          'embedded binary but failed.',
+      ),
+      m('br'),
+      m('br'),
+      m('span', 'Error message:'),
+      m('br'),
+      m('span', message),
+    ),
+  });
+}
+
+function showWebsocketConnectionIssue(message: string): void {
+  showModal({
+    title: 'Unable to connect to the device via websocket',
+    content: m(
+      'div',
+      m('div', 'trace_processor_shell --httpd is unreachable or crashed.'),
+      m('pre', message),
+    ),
+  });
+}
diff --git a/ui/src/common/recordingV2/recording_interfaces_v2.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_interfaces_v2.ts
similarity index 99%
rename from ui/src/common/recordingV2/recording_interfaces_v2.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_interfaces_v2.ts
index 954a145..c8a030e 100644
--- a/ui/src/common/recordingV2/recording_interfaces_v2.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_interfaces_v2.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {TraceConfig} from '../../protos';
+import {TraceConfig} from '../../../protos';
 
 // TargetFactory connects, disconnects and keeps track of targets.
 // There is one factory for AndroidWebusb, AndroidWebsocket, Chrome etc.
diff --git a/ui/src/common/recordingV2/recording_page_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_page_controller.ts
similarity index 94%
rename from ui/src/common/recordingV2/recording_page_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_page_controller.ts
index de568aa..76617d5 100644
--- a/ui/src/common/recordingV2/recording_page_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_page_controller.ts
@@ -12,19 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertExists, assertTrue} from '../../base/logging';
-import {currentDateHourAndMinute} from '../../base/time';
-import {RecordingManager} from '../../controller/recording_manager';
-import {AppImpl} from '../../core/app_impl';
-import {raf} from '../../core/raf_scheduler';
-import {autosaveConfigStore} from '../../frontend/record_config';
+import {assertExists, assertTrue} from '../../../base/logging';
+import {currentDateHourAndMinute} from '../../../base/time';
+import {RecordingManager} from '../recording_manager';
+import {autosaveConfigStore} from '../record_config';
 import {
   DEFAULT_ADB_WEBSOCKET_URL,
   DEFAULT_TRACED_WEBSOCKET_URL,
-} from '../../frontend/recording/recording_ui_utils';
-import {couldNotClaimInterface} from '../../frontend/recording/reset_interface_modal';
-import {TraceConfig} from '../../protos';
-import {TRACE_SUFFIX} from '../constants';
+} from '../recording_ui_utils';
+import {couldNotClaimInterface} from '../reset_interface_modal';
+import {TraceConfig} from '../../../protos';
+import {TRACE_SUFFIX} from '../../../public/trace';
 import {genTraceConfig} from './recording_config_utils';
 import {RecordingError, showRecordingModal} from './recording_error_handling';
 import {
@@ -47,6 +45,8 @@
   HostOsTargetFactory,
 } from './target_factories/host_os_target_factory';
 import {targetFactoryRegistry} from './target_factory_registry';
+import {scheduleFullRedraw} from '../../../widgets/raf';
+import {App} from '../../../public/app';
 
 // The recording page can be in any of these states. It can transition between
 // states:
@@ -250,6 +250,7 @@
 // Keeps track of the state the Ui is in. Has methods which are executed on
 // user actions such as starting/stopping/cancelling a tracing session.
 export class RecordingPageController {
+  private app: App;
   private recMgr: RecordingManager;
 
   // State of the recording page. This is set by user actions and/or automatic
@@ -267,7 +268,8 @@
   // transitions don't override one another in async functions.
   private stateGeneration = 0;
 
-  constructor(recMgr: RecordingManager) {
+  constructor(app: App, recMgr: RecordingManager) {
+    this.app = app;
     this.recMgr = recMgr;
   }
 
@@ -296,7 +298,7 @@
     }
     this.setState(state);
     this.recMgr.setRecordingStatus(undefined);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   maybeClearRecordingState(tracingSessionWrapper: TracingSessionWrapper): void {
@@ -312,7 +314,7 @@
     if (this.tracingSessionWrapper !== tracingSessionWrapper) {
       return;
     }
-    AppImpl.instance.openTraceFromBuffer({
+    this.app.openTraceFromBuffer({
       title: 'Recorded trace',
       buffer: trace.buffer,
       fileName: `trace_${currentDateHourAndMinute()}${TRACE_SUFFIX}`,
@@ -390,11 +392,11 @@
 
     if (!this.target) {
       this.setState(RecordingState.NO_TARGET);
-      raf.scheduleFullRedraw();
+      scheduleFullRedraw();
       return;
     }
     this.setState(RecordingState.TARGET_SELECTED);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
 
     this.tracingSessionWrapper = this.createTracingSessionWrapper(this.target);
     this.tracingSessionWrapper.fetchTargetInfo();
@@ -431,7 +433,7 @@
 
     const target = this.getTarget();
     const targetInfo = target.getInfo();
-    AppImpl.instance.analytics.logEvent(
+    this.app.analytics.logEvent(
       'Record Trace',
       `Record trace (${targetInfo.targetType})`,
     );
@@ -484,7 +486,7 @@
     // We redraw if:
     // 1. We received a correct buffer usage value.
     // 2. We receive a RecordingError.
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   initFactories() {
@@ -531,7 +533,7 @@
     // If the change happens for an existing target, the controller keeps the
     // currently selected target in focus.
     if (this.target && allTargets.includes(this.target)) {
-      raf.scheduleFullRedraw();
+      scheduleFullRedraw();
       return;
     }
     // If the change happens to a new target or the controller does not have a
@@ -552,7 +554,7 @@
     this.recMgr.setRecordingStatus(undefined);
     // Redrawing because this method has changed the RecordingState, which will
     // affect the display of the record_page.
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
   }
 
   private setState(state: RecordingState) {
diff --git a/ui/src/common/recordingV2/recording_utils.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_utils.ts
similarity index 100%
rename from ui/src/common/recordingV2/recording_utils.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/recording_utils.ts
diff --git a/ui/src/common/recordingV2/target_factories/android_websocket_target_factory.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_websocket_target_factory.ts
similarity index 96%
rename from ui/src/common/recordingV2/target_factories/android_websocket_target_factory.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_websocket_target_factory.ts
index 21097eb..03cda1f 100644
--- a/ui/src/common/recordingV2/target_factories/android_websocket_target_factory.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_websocket_target_factory.ts
@@ -12,7 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {RECORDING_V2_FLAG} from '../../../core/feature_flags';
 import {
   OnTargetChangeCallback,
   RecordingTargetV2,
@@ -22,7 +21,6 @@
   buildAbdWebsocketCommand,
   WEBSOCKET_CLOSED_ABNORMALLY_CODE,
 } from '../recording_utils';
-import {targetFactoryRegistry} from '../target_factory_registry';
 import {AndroidWebsocketTarget} from '../targets/android_websocket_target';
 
 export const ANDROID_WEBSOCKET_TARGET_FACTORY = 'AndroidWebsocketTargetFactory';
@@ -268,8 +266,3 @@
     this.onTargetChange = onTargetChange;
   }
 }
-
-// We only want to instantiate this class if Recording V2 is enabled.
-if (RECORDING_V2_FLAG.get()) {
-  targetFactoryRegistry.register(new AndroidWebsocketTargetFactory());
-}
diff --git a/ui/src/common/recordingV2/target_factories/android_websocket_target_factory_unittest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_websocket_target_factory_unittest.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/android_websocket_target_factory_unittest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_websocket_target_factory_unittest.ts
diff --git a/ui/src/common/recordingV2/target_factories/android_webusb_target_factory.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_webusb_target_factory.ts
similarity index 89%
rename from ui/src/common/recordingV2/target_factories/android_webusb_target_factory.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_webusb_target_factory.ts
index d27ab07..a969c31 100644
--- a/ui/src/common/recordingV2/target_factories/android_webusb_target_factory.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/android_webusb_target_factory.ts
@@ -12,9 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {getErrorMessage} from '../../../base/errors';
-import {assertExists} from '../../../base/logging';
-import {RECORDING_V2_FLAG} from '../../../core/feature_flags';
+import {getErrorMessage} from '../../../../base/errors';
+import {assertExists} from '../../../../base/logging';
 import {AdbKeyManager} from '../auth/adb_key_manager';
 import {RecordingError} from '../recording_error_handling';
 import {
@@ -23,7 +22,6 @@
   TargetFactory,
 } from '../recording_interfaces_v2';
 import {ADB_DEVICE_FILTER, findInterfaceAndEndpoint} from '../recording_utils';
-import {targetFactoryRegistry} from '../target_factory_registry';
 import {AndroidWebusbTarget} from '../targets/android_webusb_target';
 
 export const ANDROID_WEBUSB_TARGET_FACTORY = 'AndroidWebusbTargetFactory';
@@ -155,11 +153,3 @@
     return deviceValidity;
   }
 }
-
-// We only want to instantiate this class if:
-// 1. The browser implements the USB functionality.
-// 2. Recording V2 is enabled.
-// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-if (navigator.usb && RECORDING_V2_FLAG.get()) {
-  targetFactoryRegistry.register(new AndroidWebusbTargetFactory(navigator.usb));
-}
diff --git a/ui/src/common/recordingV2/target_factories/chrome_target_factory.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/chrome_target_factory.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/chrome_target_factory.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/chrome_target_factory.ts
diff --git a/ui/src/common/recordingV2/target_factories/chrome_target_factory_unittest.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/chrome_target_factory_unittest.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/chrome_target_factory_unittest.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/chrome_target_factory_unittest.ts
diff --git a/ui/src/common/recordingV2/target_factories/host_os_target_factory.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/host_os_target_factory.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/host_os_target_factory.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/host_os_target_factory.ts
diff --git a/ui/src/common/recordingV2/target_factories/index.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/index.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/index.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/index.ts
diff --git a/ui/src/common/recordingV2/target_factories/virtual_target_factory.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/virtual_target_factory.ts
similarity index 100%
rename from ui/src/common/recordingV2/target_factories/virtual_target_factory.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factories/virtual_target_factory.ts
diff --git a/ui/src/common/recordingV2/target_factory_registry.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factory_registry.ts
similarity index 96%
rename from ui/src/common/recordingV2/target_factory_registry.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factory_registry.ts
index e8de655..b34070d 100644
--- a/ui/src/common/recordingV2/target_factory_registry.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/target_factory_registry.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Registry} from '../../base/registry';
+import {Registry} from '../../../base/registry';
 import {RecordingTargetV2, TargetFactory} from './recording_interfaces_v2';
 
 export class TargetFactoryRegistry extends Registry<TargetFactory> {
diff --git a/ui/src/common/recordingV2/targets/android_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_target.ts
similarity index 96%
rename from ui/src/common/recordingV2/targets/android_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_target.ts
index 926846d..0bac1e4 100644
--- a/ui/src/common/recordingV2/targets/android_target.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_target.ts
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {fetchWithTimeout} from '../../../base/http_utils';
-import {exists} from '../../../base/utils';
-import {VERSION} from '../../../gen/perfetto_version';
+import {fetchWithTimeout} from '../../../../base/http_utils';
+import {exists} from '../../../../base/utils';
+import {VERSION} from '../../../../gen/perfetto_version';
 import {AdbConnectionImpl} from '../adb_connection_impl';
 import {
   DataSource,
diff --git a/ui/src/common/recordingV2/targets/android_virtual_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_virtual_target.ts
similarity index 100%
rename from ui/src/common/recordingV2/targets/android_virtual_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_virtual_target.ts
diff --git a/ui/src/common/recordingV2/targets/android_websocket_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_websocket_target.ts
similarity index 100%
rename from ui/src/common/recordingV2/targets/android_websocket_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_websocket_target.ts
diff --git a/ui/src/common/recordingV2/targets/android_webusb_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_webusb_target.ts
similarity index 96%
rename from ui/src/common/recordingV2/targets/android_webusb_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_webusb_target.ts
index e70a19a..dc6e64d 100644
--- a/ui/src/common/recordingV2/targets/android_webusb_target.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/android_webusb_target.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assertExists} from '../../../base/logging';
+import {assertExists} from '../../../../base/logging';
 import {AdbConnectionOverWebusb} from '../adb_connection_over_webusb';
 import {AdbKeyManager} from '../auth/adb_key_manager';
 import {OnTargetChangeCallback, TargetInfo} from '../recording_interfaces_v2';
diff --git a/ui/src/common/recordingV2/targets/chrome_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/chrome_target.ts
similarity index 100%
rename from ui/src/common/recordingV2/targets/chrome_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/chrome_target.ts
diff --git a/ui/src/common/recordingV2/targets/host_os_target.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/host_os_target.ts
similarity index 100%
rename from ui/src/common/recordingV2/targets/host_os_target.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/targets/host_os_target.ts
diff --git a/ui/src/common/recordingV2/traced_tracing_session.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/traced_tracing_session.ts
similarity index 98%
rename from ui/src/common/recordingV2/traced_tracing_session.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/traced_tracing_session.ts
index c0ba444..8687432 100644
--- a/ui/src/common/recordingV2/traced_tracing_session.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/traced_tracing_session.ts
@@ -13,8 +13,8 @@
 // limitations under the License.
 
 import protobuf from 'protobufjs/minimal';
-import {defer, Deferred} from '../../base/deferred';
-import {assertExists, assertFalse, assertTrue} from '../../base/logging';
+import {defer, Deferred} from '../../../base/deferred';
+import {assertExists, assertFalse, assertTrue} from '../../../base/logging';
 import {
   DisableTracingRequest,
   DisableTracingResponse,
@@ -33,7 +33,7 @@
   ReadBuffersRequest,
   ReadBuffersResponse,
   TraceConfig,
-} from '../../protos';
+} from '../../../protos';
 import {RecordingError} from './recording_error_handling';
 import {
   ByteStream,
@@ -50,7 +50,7 @@
   PARSING_UNRECOGNIZED_PORT,
   RECORDING_IN_PROGRESS,
 } from './recording_utils';
-import {exists} from '../../base/utils';
+import {exists} from '../../../base/utils';
 
 // See wire_protocol.proto for more details.
 const WIRE_PROTOCOL_HEADER_SIZE = 4;
diff --git a/ui/src/common/recordingV2/websocket_menu_controller.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/websocket_menu_controller.ts
similarity index 97%
rename from ui/src/common/recordingV2/websocket_menu_controller.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/websocket_menu_controller.ts
index 8b800a7..2da8f5b 100644
--- a/ui/src/common/recordingV2/websocket_menu_controller.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recordingV2/websocket_menu_controller.ts
@@ -16,7 +16,7 @@
   ADB_ENDPOINT,
   DEFAULT_WEBSOCKET_URL,
   TRACED_ENDPOINT,
-} from '../../frontend/recording/recording_ui_utils';
+} from '../recording_ui_utils';
 import {TargetFactory} from './recording_interfaces_v2';
 import {
   ANDROID_WEBSOCKET_TARGET_FACTORY,
diff --git a/ui/src/controller/recording_manager.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recording_manager.ts
similarity index 87%
rename from ui/src/controller/recording_manager.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recording_manager.ts
index 5d96017..be29691 100644
--- a/ui/src/controller/recording_manager.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recording_manager.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {createEmptyState} from '../common/empty_state';
+import {createEmptyState} from './empty_state';
 import {
   AdbRecordingTarget,
   LoadedConfig,
@@ -20,39 +20,41 @@
   RecordingTarget,
   getDefaultRecordingTargets,
   isAdbTarget,
-} from '../common/state';
-import {RECORDING_V2_FLAG} from '../core/feature_flags';
-import {raf} from '../core/raf_scheduler';
+} from './state';
 import {AdbOverWebUsb} from './adb';
 import {isGetCategoriesResponse} from './chrome_proxy_record_controller';
 import {RecordConfig, createEmptyRecordConfig} from './record_config_types';
 import {RecordController} from './record_controller';
+import {scheduleFullRedraw} from '../../widgets/raf';
+import {App} from '../../public/app';
+import {targetFactoryRegistry} from './recordingV2/target_factory_registry';
+import {AndroidWebsocketTargetFactory} from './recordingV2/target_factories/android_websocket_target_factory';
+import {AndroidWebusbTargetFactory} from './recordingV2/target_factories/android_webusb_target_factory';
+import {exists} from '../../base/utils';
 
 const EXTENSION_ID = 'lfmkphfpdbjijhpomgecfikhfohaoine';
 
 // TODO(primiano): this class and RecordController should be merged. I'm keeping
 // them separate for now to reduce scope of refactorings.
 export class RecordingManager {
+  readonly app: App;
   private _state: RecordingState = createEmptyState();
   private recCtl: RecordController;
 
-  // TODO(primiano): this singleton is temporary. RecordingManager shoudl be
-  // injected in all the recording pages and the instance should be created and
-  // owned by the recording plugin. But for now we don't have a plugin.
-  private static _instance: RecordingManager | undefined = undefined;
-  static get instance() {
-    if (this._instance === undefined) {
-      this._instance = new RecordingManager();
-    }
-    return this._instance;
-  }
-
-  constructor() {
+  constructor(app: App, useRecordingV2: boolean) {
+    this.app = app;
     const extensionLocalChannel = new MessageChannel();
-    this.recCtl = new RecordController(this, extensionLocalChannel.port1);
+    this.recCtl = new RecordController(app, this, extensionLocalChannel.port1);
     this.setupExtentionPort(extensionLocalChannel);
 
-    if (!RECORDING_V2_FLAG.get()) {
+    if (useRecordingV2) {
+      targetFactoryRegistry.register(new AndroidWebsocketTargetFactory());
+      if (exists(navigator.usb)) {
+        targetFactoryRegistry.register(
+          new AndroidWebusbTargetFactory(navigator.usb),
+        );
+      }
+    } else {
       this.updateAvailableAdbDevices();
       try {
         navigator.usb.addEventListener('connect', () =>
@@ -156,7 +158,7 @@
         (message: object, _port: chrome.runtime.Port) => {
           if (isGetCategoriesResponse(message)) {
             this._state.chromeCategories = message.categories;
-            raf.scheduleFullRedraw();
+            scheduleFullRedraw();
             return;
           }
           extensionLocalChannel.port2.postMessage(message);
@@ -191,7 +193,7 @@
 
     this.setAvailableAdbDevices(availableAdbDevices);
     this.selectAndroidDeviceIfAvailable(availableAdbDevices, recordingTarget);
-    raf.scheduleFullRedraw();
+    scheduleFullRedraw();
     return availableAdbDevices;
   }
 
diff --git a/ui/src/frontend/recording/recording_multiple_choice.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recording_multiple_choice.ts
similarity index 93%
rename from ui/src/frontend/recording/recording_multiple_choice.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recording_multiple_choice.ts
index 27a83fc..0e34f5c 100644
--- a/ui/src/frontend/recording/recording_multiple_choice.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recording_multiple_choice.ts
@@ -16,9 +16,9 @@
 import {
   RecordingTargetV2,
   TargetFactory,
-} from '../../common/recordingV2/recording_interfaces_v2';
-import {RecordingPageController} from '../../common/recordingV2/recording_page_controller';
-import {RECORDING_MODAL_DIALOG_KEY} from '../../common/recordingV2/recording_utils';
+} from './recordingV2/recording_interfaces_v2';
+import {RecordingPageController} from './recordingV2/recording_page_controller';
+import {RECORDING_MODAL_DIALOG_KEY} from './recordingV2/recording_utils';
 import {closeModal} from '../../widgets/modal';
 
 interface RecordingMultipleChoiceAttrs {
diff --git a/ui/src/frontend/recording/recording_sections.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recording_sections.ts
similarity index 86%
rename from ui/src/frontend/recording/recording_sections.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recording_sections.ts
index 03ebff8..c83b9e0 100644
--- a/ui/src/frontend/recording/recording_sections.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recording_sections.ts
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {DataSource} from '../../common/recordingV2/recording_interfaces_v2';
-import {RecordingState} from '../../common/state';
+import {DataSource} from './recordingV2/recording_interfaces_v2';
+import {RecordingState} from './state';
 
 export interface RecordingSectionAttrs {
   recState: RecordingState;
diff --git a/ui/src/frontend/recording/recording_settings.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recording_settings.ts
similarity index 96%
rename from ui/src/frontend/recording/recording_settings.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recording_settings.ts
index c61b49d..e3058be 100644
--- a/ui/src/frontend/recording/recording_settings.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/recording_settings.ts
@@ -13,8 +13,8 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {RecordMode} from '../../common/state';
-import {Slider} from '../record_widgets';
+import {RecordMode} from './state';
+import {Slider} from './record_widgets';
 import {RecordingSectionAttrs} from './recording_sections';
 import {assetSrc} from '../../base/assets';
 
diff --git a/ui/src/frontend/recording/recording_ui_utils.ts b/ui/src/plugins/dev.perfetto.RecordTrace/recording_ui_utils.ts
similarity index 100%
rename from ui/src/frontend/recording/recording_ui_utils.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/recording_ui_utils.ts
diff --git a/ui/src/frontend/recording/reset_interface_modal.ts b/ui/src/plugins/dev.perfetto.RecordTrace/reset_interface_modal.ts
similarity index 100%
rename from ui/src/frontend/recording/reset_interface_modal.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/reset_interface_modal.ts
diff --git a/ui/src/frontend/recording/reset_target_modal.ts b/ui/src/plugins/dev.perfetto.RecordTrace/reset_target_modal.ts
similarity index 91%
rename from ui/src/frontend/recording/reset_target_modal.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/reset_target_modal.ts
index 4d3feb3..4d3d048 100644
--- a/ui/src/frontend/recording/reset_target_modal.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/reset_target_modal.ts
@@ -13,19 +13,19 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {RecordingPageController} from '../../common/recordingV2/recording_page_controller';
+import {RecordingPageController} from './recordingV2/recording_page_controller';
 import {
   EXTENSION_URL,
   RECORDING_MODAL_DIALOG_KEY,
-} from '../../common/recordingV2/recording_utils';
+} from './recordingV2/recording_utils';
 import {
   CHROME_TARGET_FACTORY,
   ChromeTargetFactory,
-} from '../../common/recordingV2/target_factories/chrome_target_factory';
-import {targetFactoryRegistry} from '../../common/recordingV2/target_factory_registry';
-import {WebsocketMenuController} from '../../common/recordingV2/websocket_menu_controller';
+} from './recordingV2/target_factories/chrome_target_factory';
+import {targetFactoryRegistry} from './recordingV2/target_factory_registry';
+import {WebsocketMenuController} from './recordingV2/websocket_menu_controller';
 import {closeModal, showModal} from '../../widgets/modal';
-import {CodeSnippet} from '../record_widgets';
+import {CodeSnippet} from './record_widgets';
 import {RecordingMultipleChoice} from './recording_multiple_choice';
 
 const RUN_WEBSOCKET_CMD =
diff --git a/ui/src/common/state.ts b/ui/src/plugins/dev.perfetto.RecordTrace/state.ts
similarity index 98%
rename from ui/src/common/state.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/state.ts
index 9c361f6..b94074b 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/state.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {RecordConfig} from '../controller/record_config_types';
+import {RecordConfig} from './record_config_types';
 
 export const MAX_TIME = 180;
 
diff --git a/ui/src/core/trace_config_utils.ts b/ui/src/plugins/dev.perfetto.RecordTrace/trace_config_utils.ts
similarity index 96%
rename from ui/src/core/trace_config_utils.ts
rename to ui/src/plugins/dev.perfetto.RecordTrace/trace_config_utils.ts
index e05f711..c7697dd 100644
--- a/ui/src/core/trace_config_utils.ts
+++ b/ui/src/plugins/dev.perfetto.RecordTrace/trace_config_utils.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {EnableTracingRequest, TraceConfig} from '../protos';
+import {EnableTracingRequest, TraceConfig} from '../../protos';
 
 // In this file are contained a few functions to simplify the proto parsing.
 
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
index 7bbcace..327a179 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -43,7 +43,6 @@
 import {LazyTreeNode, Tree, TreeNode} from '../../widgets/tree';
 import {VegaView} from '../../widgets/vega_view';
 import {PageAttrs} from '../../public/page';
-import {PopupMenuButton} from '../../widgets/popup_menu';
 import {TableShowcase} from './table_showcase';
 import {TreeTable, TreeTableAttrs} from '../../frontend/widgets/treetable';
 import {Intent} from '../../widgets/common';
@@ -904,30 +903,6 @@
         },
       }),
       m(WidgetShowcase, {
-        label: 'PopupMenu',
-        renderWidget: () => {
-          return m(PopupMenuButton, {
-            icon: 'description',
-            items: [
-              {itemType: 'regular', text: 'New', callback: () => {}},
-              {itemType: 'regular', text: 'Open', callback: () => {}},
-              {itemType: 'regular', text: 'Save', callback: () => {}},
-              {itemType: 'regular', text: 'Delete', callback: () => {}},
-              {
-                itemType: 'group',
-                text: 'Share',
-                itemId: 'foo',
-                children: [
-                  {itemType: 'regular', text: 'Friends', callback: () => {}},
-                  {itemType: 'regular', text: 'Family', callback: () => {}},
-                  {itemType: 'regular', text: 'Everyone', callback: () => {}},
-                ],
-              },
-            ],
-          });
-        },
-      }),
-      m(WidgetShowcase, {
         label: 'Menu',
         renderWidget: () =>
           m(
diff --git a/ui/src/public/app.ts b/ui/src/public/app.ts
index 5dcd368..50def57 100644
--- a/ui/src/public/app.ts
+++ b/ui/src/public/app.ts
@@ -60,4 +60,12 @@
    * Navigate to a new page.
    */
   navigate(newHash: string): void;
+
+  openTraceFromFile(file: File): void;
+  openTraceFromUrl(url: string): void;
+  openTraceFromBuffer(args: {
+    buffer: ArrayBuffer;
+    title: string;
+    fileName: string;
+  }): void;
 }
diff --git a/ui/src/public/trace.ts b/ui/src/public/trace.ts
index 6e60c79..95db546 100644
--- a/ui/src/public/trace.ts
+++ b/ui/src/public/trace.ts
@@ -96,3 +96,5 @@
 export interface TraceAttrs {
   trace: Trace;
 }
+
+export const TRACE_SUFFIX = '.perfetto-trace';
diff --git a/ui/src/widgets/popup_menu.ts b/ui/src/widgets/popup_menu.ts
deleted file mode 100644
index 737815c..0000000
--- a/ui/src/widgets/popup_menu.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import m from 'mithril';
-import {SortDirection} from '../base/comparison_utils';
-import {scheduleFullRedraw} from './raf';
-
-export interface RegularPopupMenuItem {
-  itemType: 'regular';
-  // Display text
-  text: string;
-  // Action on menu item click
-  callback: () => void;
-}
-
-// Helper function for simplifying defining menus.
-export function menuItem(
-  text: string,
-  action: () => void,
-): RegularPopupMenuItem {
-  return {
-    itemType: 'regular',
-    text,
-    callback: action,
-  };
-}
-
-export interface GroupPopupMenuItem {
-  itemType: 'group';
-  text: string;
-  itemId: string;
-  children: PopupMenuItem[];
-}
-
-export type PopupMenuItem = RegularPopupMenuItem | GroupPopupMenuItem;
-
-export interface PopupMenuButtonAttrs {
-  // Icon for button opening a menu
-  icon: string;
-  // List of popup menu items
-  items: PopupMenuItem[];
-}
-
-// To ensure having at most one popup menu on the screen at a time, we need to
-// listen to click events on the whole page and close currently opened popup, if
-// there's any. This class, used as a singleton, does exactly that.
-class PopupHolder {
-  // Invariant: global listener should be register if and only if this.popup is
-  // not undefined.
-  popup: PopupMenuButton | undefined = undefined;
-  initialized = false;
-  listener: (e: MouseEvent) => void;
-
-  constructor() {
-    this.listener = (e: MouseEvent) => {
-      // Only handle those events that are not part of dropdown menu themselves.
-      const hasDropdown =
-        e.composedPath().find(PopupHolder.isDropdownElement) !== undefined;
-      if (!hasDropdown) {
-        this.ensureHidden();
-      }
-    };
-  }
-
-  static isDropdownElement(target: EventTarget) {
-    if (target instanceof HTMLElement) {
-      return target.tagName === 'DIV' && target.classList.contains('dropdown');
-    }
-    return false;
-  }
-
-  ensureHidden() {
-    if (this.popup !== undefined) {
-      this.popup.setVisible(false);
-    }
-  }
-
-  clear() {
-    if (this.popup !== undefined) {
-      this.popup = undefined;
-      window.removeEventListener('click', this.listener);
-    }
-  }
-
-  showPopup(popup: PopupMenuButton) {
-    this.ensureHidden();
-    this.popup = popup;
-    window.addEventListener('click', this.listener);
-  }
-}
-
-// Singleton instance of PopupHolder
-const popupHolder = new PopupHolder();
-
-// For a table column that can be sorted; the standard popup icon should
-// reflect the current sorting direction. This function returns an icon
-// corresponding to optional SortDirection according to which the column is
-// sorted. (Optional because column might be unsorted)
-export function popupMenuIcon(sortDirection?: SortDirection) {
-  switch (sortDirection) {
-    case undefined:
-      return 'more_horiz';
-    case 'DESC':
-      return 'arrow_drop_down';
-    case 'ASC':
-      return 'arrow_drop_up';
-  }
-}
-
-// Component that displays a button that shows a popup menu on click.
-export class PopupMenuButton implements m.ClassComponent<PopupMenuButtonAttrs> {
-  popupShown = false;
-  expandedGroups: Set<string> = new Set();
-
-  setVisible(visible: boolean) {
-    this.popupShown = visible;
-    if (this.popupShown) {
-      popupHolder.showPopup(this);
-    } else {
-      popupHolder.clear();
-    }
-    scheduleFullRedraw();
-  }
-
-  renderItem(item: PopupMenuItem): m.Child {
-    switch (item.itemType) {
-      case 'regular':
-        return m(
-          'button.open-menu',
-          {
-            onclick: () => {
-              item.callback();
-              // Hide the menu item after the action has been invoked
-              this.setVisible(false);
-            },
-          },
-          item.text,
-        );
-      case 'group':
-        const isExpanded = this.expandedGroups.has(item.itemId);
-        return m(
-          'div',
-          m(
-            'button.open-menu.disallow-selection',
-            {
-              onclick: () => {
-                if (this.expandedGroups.has(item.itemId)) {
-                  this.expandedGroups.delete(item.itemId);
-                } else {
-                  this.expandedGroups.add(item.itemId);
-                }
-                scheduleFullRedraw();
-              },
-            },
-            // Show text with up/down arrow, depending on expanded state.
-            item.text + (isExpanded ? ' \u25B2' : ' \u25BC'),
-          ),
-          isExpanded
-            ? m(
-                'div.nested-menu',
-                item.children.map((item) => this.renderItem(item)),
-              )
-            : null,
-        );
-    }
-  }
-
-  view(vnode: m.Vnode<PopupMenuButtonAttrs, this>) {
-    return m(
-      '.dropdown',
-      m(
-        '.dropdown-button',
-        {
-          onclick: () => {
-            this.setVisible(!this.popupShown);
-          },
-        },
-        vnode.children,
-        m('i.material-icons', vnode.attrs.icon),
-      ),
-      m(
-        this.popupShown ? '.popup-menu.opened' : '.popup-menu.closed',
-        vnode.attrs.items.map((item) => this.renderItem(item)),
-      ),
-    );
-  }
-}
diff --git a/ui/src/widgets/table.ts b/ui/src/widgets/table.ts
index ee195d0..1389907 100644
--- a/ui/src/widgets/table.ts
+++ b/ui/src/widgets/table.ts
@@ -22,17 +22,28 @@
   SortDirection,
   withDirection,
 } from '../base/comparison_utils';
-import {
-  menuItem,
-  PopupMenuButton,
-  popupMenuIcon,
-  PopupMenuItem,
-} from './popup_menu';
 import {scheduleFullRedraw} from './raf';
+import {MenuItem, PopupMenu2} from './menu';
+import {Button} from './button';
+
+// For a table column that can be sorted; the standard popup icon should
+// reflect the current sorting direction. This function returns an icon
+// corresponding to optional SortDirection according to which the column is
+// sorted. (Optional because column might be unsorted)
+export function popupMenuIcon(sortDirection?: SortDirection) {
+  switch (sortDirection) {
+    case undefined:
+      return 'more_horiz';
+    case 'DESC':
+      return 'arrow_drop_down';
+    case 'ASC':
+      return 'arrow_drop_up';
+  }
+}
 
 export interface ColumnDescriptorAttrs<T> {
   // Context menu items displayed on the column header.
-  contextMenu?: PopupMenuItem[];
+  contextMenu?: m.Child[];
 
   // Unique column ID, used to identify which column is currently sorted.
   columnId?: string;
@@ -49,7 +60,7 @@
   name: string;
   render: (row: T) => m.Child;
   id: string;
-  contextMenu?: PopupMenuItem[];
+  contextMenu?: m.Child[];
   ordering?: ComparisonFn<T>;
 
   constructor(
@@ -81,7 +92,7 @@
 export function numberColumn<T>(
   name: string,
   getter: (t: T) => number,
-  contextMenu?: PopupMenuItem[],
+  contextMenu?: m.Child[],
 ): ColumnDescriptor<T> {
   return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
 }
@@ -89,7 +100,7 @@
 export function stringColumn<T>(
   name: string,
   getter: (t: T) => string,
-  contextMenu?: PopupMenuItem[],
+  contextMenu?: m.Child[],
 ): ColumnDescriptor<T> {
   return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
 }
@@ -191,33 +202,42 @@
     if (column.ordering !== undefined) {
       const ordering = column.ordering;
       currDirection = directionOnIndex(column.id, vnode.attrs.data.sortingInfo);
-      const newItems: PopupMenuItem[] = [];
+      const newItems: m.Child[] = [];
       if (currDirection !== 'ASC') {
         newItems.push(
-          menuItem('Sort ascending', () => {
-            vnode.attrs.data.reorder({
-              columnId: column.id,
-              direction: 'ASC',
-              ordering,
-            });
+          m(MenuItem, {
+            label: 'Sort ascending',
+            onclick: () => {
+              vnode.attrs.data.reorder({
+                columnId: column.id,
+                direction: 'ASC',
+                ordering,
+              });
+            },
           }),
         );
       }
       if (currDirection !== 'DESC') {
         newItems.push(
-          menuItem('Sort descending', () => {
-            vnode.attrs.data.reorder({
-              columnId: column.id,
-              direction: 'DESC',
-              ordering,
-            });
+          m(MenuItem, {
+            label: 'Sort descending',
+            onclick: () => {
+              vnode.attrs.data.reorder({
+                columnId: column.id,
+                direction: 'DESC',
+                ordering,
+              });
+            },
           }),
         );
       }
       if (currDirection !== undefined) {
         newItems.push(
-          menuItem('Restore original order', () => {
-            vnode.attrs.data.resetOrder();
+          m(MenuItem, {
+            label: 'Restore original order',
+            onclick: () => {
+              vnode.attrs.data.resetOrder();
+            },
           }),
         );
       }
@@ -227,12 +247,14 @@
     return m(
       'td',
       column.name,
-      items === undefined
-        ? null
-        : m(PopupMenuButton, {
-            icon: popupMenuIcon(currDirection),
-            items,
-          }),
+      items &&
+        m(
+          PopupMenu2,
+          {
+            trigger: m(Button, {icon: popupMenuIcon(currDirection)}),
+          },
+          items,
+        ),
     );
   }