Merge "ui: tp: plumb errors in NotifyEndOfFile into UI" into main
diff --git a/Android.bp b/Android.bp
index 69c599d..a0b685c 100644
--- a/Android.bp
+++ b/Android.bp
@@ -5481,6 +5481,7 @@
         "protos/perfetto/metrics/chrome/dropped_frames.proto",
         "protos/perfetto/metrics/chrome/frame_times.proto",
         "protos/perfetto/metrics/chrome/histogram_hashes.proto",
+        "protos/perfetto/metrics/chrome/histogram_summaries.proto",
         "protos/perfetto/metrics/chrome/long_latency.proto",
         "protos/perfetto/metrics/chrome/media_metric.proto",
         "protos/perfetto/metrics/chrome/performance_mark_hashes.proto",
@@ -6790,6 +6791,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
@@ -7220,6 +7222,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
@@ -7312,6 +7315,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.gen.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.gen.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.gen.cc",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.gen.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.gen.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.gen.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.gen.cc",
@@ -7404,6 +7408,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.gen.h",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.gen.h",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.gen.h",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.gen.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.gen.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.gen.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.gen.h",
@@ -7492,6 +7497,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
@@ -7583,6 +7589,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.pb.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.pb.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.pb.cc",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.pb.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.pb.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.pb.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.pb.cc",
@@ -7674,6 +7681,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.pb.h",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.pb.h",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.pb.h",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.pb.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.pb.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.pb.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.pb.h",
@@ -7762,6 +7770,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
@@ -7854,6 +7863,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.pbzero.cc",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.pbzero.cc",
@@ -7946,6 +7956,7 @@
         "external/perfetto/protos/perfetto/trace/ftrace/fastrpc.pbzero.h",
         "external/perfetto/protos/perfetto/trace/ftrace/fence.pbzero.h",
         "external/perfetto/protos/perfetto/trace/ftrace/filemap.pbzero.h",
+        "external/perfetto/protos/perfetto/trace/ftrace/fs.pbzero.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace.pbzero.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event.pbzero.h",
         "external/perfetto/protos/perfetto/trace/ftrace/ftrace_event_bundle.pbzero.h",
@@ -13224,6 +13235,7 @@
         "src/trace_processor/metrics/sql/chrome/chrome_args_class_names.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_event_metadata.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_histogram_hashes.sql",
+        "src/trace_processor/metrics/sql/chrome/chrome_histogram_summaries.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals_base.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals_template.sql",
@@ -15152,6 +15164,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
@@ -16493,6 +16506,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
diff --git a/BUILD b/BUILD
index d4f1f1b..d4d3aff 100644
--- a/BUILD
+++ b/BUILD
@@ -2541,6 +2541,7 @@
         "src/trace_processor/metrics/sql/chrome/chrome_args_class_names.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_event_metadata.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_histogram_hashes.sql",
+        "src/trace_processor/metrics/sql/chrome/chrome_histogram_summaries.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals_base.sql",
         "src/trace_processor/metrics/sql/chrome/chrome_input_to_browser_intervals_template.sql",
@@ -5230,6 +5231,7 @@
         "protos/perfetto/metrics/chrome/dropped_frames.proto",
         "protos/perfetto/metrics/chrome/frame_times.proto",
         "protos/perfetto/metrics/chrome/histogram_hashes.proto",
+        "protos/perfetto/metrics/chrome/histogram_summaries.proto",
         "protos/perfetto/metrics/chrome/long_latency.proto",
         "protos/perfetto/metrics/chrome/media_metric.proto",
         "protos/perfetto/metrics/chrome/performance_mark_hashes.proto",
@@ -5625,6 +5627,7 @@
         "protos/perfetto/trace/ftrace/fastrpc.proto",
         "protos/perfetto/trace/ftrace/fence.proto",
         "protos/perfetto/trace/ftrace/filemap.proto",
+        "protos/perfetto/trace/ftrace/fs.proto",
         "protos/perfetto/trace/ftrace/ftrace.proto",
         "protos/perfetto/trace/ftrace/ftrace_event.proto",
         "protos/perfetto/trace/ftrace/ftrace_event_bundle.proto",
diff --git a/protos/perfetto/metrics/chrome/BUILD.gn b/protos/perfetto/metrics/chrome/BUILD.gn
index bf47885..c0b0c36 100644
--- a/protos/perfetto/metrics/chrome/BUILD.gn
+++ b/protos/perfetto/metrics/chrome/BUILD.gn
@@ -27,6 +27,7 @@
     "dropped_frames.proto",
     "frame_times.proto",
     "histogram_hashes.proto",
+    "histogram_summaries.proto",
     "long_latency.proto",
     "media_metric.proto",
     "performance_mark_hashes.proto",
diff --git a/protos/perfetto/metrics/chrome/all_chrome_metrics.proto b/protos/perfetto/metrics/chrome/all_chrome_metrics.proto
index 596bc7f..ebc0ac5 100644
--- a/protos/perfetto/metrics/chrome/all_chrome_metrics.proto
+++ b/protos/perfetto/metrics/chrome/all_chrome_metrics.proto
@@ -23,6 +23,7 @@
 import "protos/perfetto/metrics/chrome/dropped_frames.proto";
 import "protos/perfetto/metrics/chrome/frame_times.proto";
 import "protos/perfetto/metrics/chrome/histogram_hashes.proto";
+import "protos/perfetto/metrics/chrome/histogram_summaries.proto";
 import "protos/perfetto/metrics/chrome/long_latency.proto";
 import "protos/perfetto/metrics/chrome/media_metric.proto";
 import "protos/perfetto/metrics/chrome/performance_mark_hashes.proto";
@@ -53,4 +54,5 @@
   optional ChromeUnsymbolizedArgs chrome_unsymbolized_args = 1014;
   optional ChromeArgsClassNames chrome_args_class_names = 1015;
   optional ChromeScrollJankV3 chrome_scroll_jank_v3 = 1017;
+  optional ChromeHistogramSummaries chrome_histogram_summaries = 1018;
 }
diff --git a/protos/perfetto/metrics/chrome/histogram_summaries.proto b/protos/perfetto/metrics/chrome/histogram_summaries.proto
new file mode 100644
index 0000000..57dad94
--- /dev/null
+++ b/protos/perfetto/metrics/chrome/histogram_summaries.proto
@@ -0,0 +1,43 @@
+
+/*
+ * 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.
+ */
+
+syntax = "proto2";
+
+package perfetto.protos;
+
+message HistogramSummary {
+  // The name of the histogram event
+  optional string name = 1;
+  // The avarage value of the histogram event
+  optional int64 mean = 2;
+  // The number of the histogram event in the trace track
+  optional uint32 count = 3;
+  // The sum of value of the histogram event
+  optional int64 sum = 4;
+  // The maximum value of the histogram event
+  optional int64 max = 5;
+  // The 90 percentile value of the histogram event
+  optional int64 p90 = 6;
+  // The 50 percentile (median) value of the histogram event
+  optional int64 p50 = 7;
+}
+
+// The list of the summary of Chrome Histograms in trace track events.
+// This includes the statistic information of each histograms from Chrome.
+message ChromeHistogramSummaries {
+  repeated HistogramSummary histogram_summary = 1;
+}
diff --git a/protos/perfetto/trace/ftrace/all_protos.gni b/protos/perfetto/trace/ftrace/all_protos.gni
index 7319dd4..8565125 100644
--- a/protos/perfetto/trace/ftrace/all_protos.gni
+++ b/protos/perfetto/trace/ftrace/all_protos.gni
@@ -42,6 +42,7 @@
   "fastrpc.proto",
   "fence.proto",
   "filemap.proto",
+  "fs.proto",
   "ftrace.proto",
   "g2d.proto",
   "google_icc_trace.proto",
diff --git a/protos/perfetto/trace/ftrace/fs.proto b/protos/perfetto/trace/ftrace/fs.proto
new file mode 100644
index 0000000..4cc3531
--- /dev/null
+++ b/protos/perfetto/trace/ftrace/fs.proto
@@ -0,0 +1,15 @@
+// Autogenerated by:
+// ../../src/tools/ftrace_proto_gen/ftrace_proto_gen.cc
+// Do not edit.
+
+syntax = "proto2";
+package perfetto.protos;
+
+message DoSysOpenFtraceEvent {
+  optional string filename = 1;
+  optional int32 flags = 2;
+  optional int32 mode = 3;
+}
+message OpenExecFtraceEvent {
+  optional string filename = 1;
+}
diff --git a/protos/perfetto/trace/ftrace/ftrace_event.proto b/protos/perfetto/trace/ftrace/ftrace_event.proto
index b363eb0..40e7113 100644
--- a/protos/perfetto/trace/ftrace/ftrace_event.proto
+++ b/protos/perfetto/trace/ftrace/ftrace_event.proto
@@ -42,6 +42,7 @@
 import "protos/perfetto/trace/ftrace/fastrpc.proto";
 import "protos/perfetto/trace/ftrace/fence.proto";
 import "protos/perfetto/trace/ftrace/filemap.proto";
+import "protos/perfetto/trace/ftrace/fs.proto";
 import "protos/perfetto/trace/ftrace/ftrace.proto";
 import "protos/perfetto/trace/ftrace/g2d.proto";
 import "protos/perfetto/trace/ftrace/google_icc_trace.proto";
@@ -683,5 +684,7 @@
     DevfreqFrequencyFtraceEvent devfreq_frequency = 541;
     KprobeEvent kprobe_event = 542;
     ParamSetValueCpmFtraceEvent param_set_value_cpm = 543;
+    DoSysOpenFtraceEvent do_sys_open = 544;
+    OpenExecFtraceEvent open_exec = 545;
   }
 }
diff --git a/protos/perfetto/trace/perfetto_trace.proto b/protos/perfetto/trace/perfetto_trace.proto
index 5e07a0c..6104064 100644
--- a/protos/perfetto/trace/perfetto_trace.proto
+++ b/protos/perfetto/trace/perfetto_trace.proto
@@ -8805,6 +8805,19 @@
 
 // End of protos/perfetto/trace/ftrace/filemap.proto
 
+// Begin of protos/perfetto/trace/ftrace/fs.proto
+
+message DoSysOpenFtraceEvent {
+  optional string filename = 1;
+  optional int32 flags = 2;
+  optional int32 mode = 3;
+}
+message OpenExecFtraceEvent {
+  optional string filename = 1;
+}
+
+// End of protos/perfetto/trace/ftrace/fs.proto
+
 // Begin of protos/perfetto/trace/ftrace/ftrace.proto
 
 message PrintFtraceEvent {
@@ -11424,6 +11437,8 @@
     DevfreqFrequencyFtraceEvent devfreq_frequency = 541;
     KprobeEvent kprobe_event = 542;
     ParamSetValueCpmFtraceEvent param_set_value_cpm = 543;
+    DoSysOpenFtraceEvent do_sys_open = 544;
+    OpenExecFtraceEvent open_exec = 545;
   }
 }
 
diff --git a/src/tools/ftrace_proto_gen/event_list b/src/tools/ftrace_proto_gen/event_list
index 8c99f49..7eeadfb 100644
--- a/src/tools/ftrace_proto_gen/event_list
+++ b/src/tools/ftrace_proto_gen/event_list
@@ -537,3 +537,5 @@
 sched/sched_wakeup_task_attr
 devfreq/devfreq_frequency
 cpm_trace/param_set_value_cpm
+fs/do_sys_open
+fs/open_exec
diff --git a/src/trace_processor/importers/ftrace/ftrace_descriptors.cc b/src/trace_processor/importers/ftrace/ftrace_descriptors.cc
index 53085f9..c9d3970 100644
--- a/src/trace_processor/importers/ftrace/ftrace_descriptors.cc
+++ b/src/trace_processor/importers/ftrace/ftrace_descriptors.cc
@@ -24,7 +24,7 @@
 namespace trace_processor {
 namespace {
 
-std::array<FtraceMessageDescriptor, 544> descriptors{{
+std::array<FtraceMessageDescriptor, 546> descriptors{{
     {nullptr, 0, {}},
     {nullptr, 0, {}},
     {nullptr, 0, {}},
@@ -6016,6 +6016,24 @@
             {"timestamp", ProtoSchemaType::kInt64},
         },
     },
+    {
+        "do_sys_open",
+        3,
+        {
+            {},
+            {"filename", ProtoSchemaType::kString},
+            {"flags", ProtoSchemaType::kInt32},
+            {"mode", ProtoSchemaType::kInt32},
+        },
+    },
+    {
+        "open_exec",
+        1,
+        {
+            {},
+            {"filename", ProtoSchemaType::kString},
+        },
+    },
 }};
 
 }  // namespace
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/chrome/BUILD.gn b/src/trace_processor/metrics/sql/chrome/BUILD.gn
index 30962b8..7d10aeb 100644
--- a/src/trace_processor/metrics/sql/chrome/BUILD.gn
+++ b/src/trace_processor/metrics/sql/chrome/BUILD.gn
@@ -26,6 +26,7 @@
     "chrome_args_class_names.sql",
     "chrome_event_metadata.sql",
     "chrome_histogram_hashes.sql",
+    "chrome_histogram_summaries.sql",
     "chrome_input_to_browser_intervals.sql",
     "chrome_input_to_browser_intervals_base.sql",
     "chrome_input_to_browser_intervals_template.sql",
diff --git a/src/trace_processor/metrics/sql/chrome/chrome_histogram_summaries.sql b/src/trace_processor/metrics/sql/chrome/chrome_histogram_summaries.sql
new file mode 100644
index 0000000..7946491
--- /dev/null
+++ b/src/trace_processor/metrics/sql/chrome/chrome_histogram_summaries.sql
@@ -0,0 +1,49 @@
+--
+-- Copyright 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
+--
+--     https://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.
+--
+
+INCLUDE PERFETTO MODULE chrome.histograms;
+
+DROP VIEW IF EXISTS HistogramSummaryTable;
+CREATE PERFETTO VIEW HistogramSummaryTable AS
+SELECT
+    hist.name AS histname,
+    CAST(AVG(hist.value) AS INTEGER) AS mean_histval,
+    COUNT(*) AS hist_count,
+    CAST(SUM(hist.value) AS INTEGER) AS sum_histval,
+    CAST(MAX(hist.value) AS INTEGER) AS max_histval,
+    CAST(PERCENTILE(hist.value, 90) AS INTEGER) AS p90_histval,
+    CAST(PERCENTILE(hist.value, 50) AS INTEGER) AS p50_histval
+FROM chrome_histograms hist
+GROUP BY hist.name;
+
+DROP VIEW IF EXISTS chrome_histogram_summaries_output;
+CREATE PERFETTO VIEW chrome_histogram_summaries_output AS
+SELECT ChromeHistogramSummaries(
+    'histogram_summary', (
+        SELECT RepeatedField(
+            HistogramSummary(
+                'name', histname,
+                'mean', mean_histval,
+                'count', hist_count,
+                'sum', sum_histval,
+                'max', max_histval,
+                'p90', p90_histval,
+                'p50', p50_histval
+            )
+        )
+        FROM HistogramSummaryTable
+    )
+);
diff --git a/src/traced/probes/ftrace/event_info.cc b/src/traced/probes/ftrace/event_info.cc
index 2cd9b20..f93e6e4 100644
--- a/src/traced/probes/ftrace/event_info.cc
+++ b/src/traced/probes/ftrace/event_info.cc
@@ -5045,6 +5045,32 @@
        kUnsetFtraceId,
        98,
        kUnsetSize},
+      {"do_sys_open",
+       "fs",
+       {
+           {kUnsetOffset, kUnsetSize, FtraceFieldType::kInvalidFtraceFieldType,
+            "filename", 1, ProtoSchemaType::kString,
+            TranslationStrategy::kInvalidTranslationStrategy},
+           {kUnsetOffset, kUnsetSize, FtraceFieldType::kInvalidFtraceFieldType,
+            "flags", 2, ProtoSchemaType::kInt32,
+            TranslationStrategy::kInvalidTranslationStrategy},
+           {kUnsetOffset, kUnsetSize, FtraceFieldType::kInvalidFtraceFieldType,
+            "mode", 3, ProtoSchemaType::kInt32,
+            TranslationStrategy::kInvalidTranslationStrategy},
+       },
+       kUnsetFtraceId,
+       544,
+       kUnsetSize},
+      {"open_exec",
+       "fs",
+       {
+           {kUnsetOffset, kUnsetSize, FtraceFieldType::kInvalidFtraceFieldType,
+            "filename", 1, ProtoSchemaType::kString,
+            TranslationStrategy::kInvalidTranslationStrategy},
+       },
+       kUnsetFtraceId,
+       545,
+       kUnsetSize},
       {"print",
        "ftrace",
        {
diff --git a/src/traced/probes/ftrace/test/data/synthetic/events/fs/do_sys_open/format b/src/traced/probes/ftrace/test/data/synthetic/events/fs/do_sys_open/format
new file mode 100644
index 0000000..d6bac92
--- /dev/null
+++ b/src/traced/probes/ftrace/test/data/synthetic/events/fs/do_sys_open/format
@@ -0,0 +1,13 @@
+name: do_sys_open
+ID: 685
+format:
+	field:unsigned short common_type;	offset:0;	size:2;	signed:0;
+	field:unsigned char common_flags;	offset:2;	size:1;	signed:0;
+	field:unsigned char common_preempt_count;	offset:3;	size:1;	signed:0;
+	field:int common_pid;	offset:4;	size:4;	signed:1;
+
+	field:__data_loc char[] filename;	offset:8;	size:4;	signed:1;
+	field:int flags;	offset:12;	size:4;	signed:1;
+	field:int mode;	offset:16;	size:4;	signed:1;
+
+print fmt: ""%s" %x %o", __get_str(filename), REC->flags, REC->mode
diff --git a/src/traced/probes/ftrace/test/data/synthetic/events/fs/open_exec/format b/src/traced/probes/ftrace/test/data/synthetic/events/fs/open_exec/format
new file mode 100644
index 0000000..9f6fe3d
--- /dev/null
+++ b/src/traced/probes/ftrace/test/data/synthetic/events/fs/open_exec/format
@@ -0,0 +1,11 @@
+name: open_exec
+ID: 686
+format:
+	field:unsigned short common_type;	offset:0;	size:2;	signed:0;
+	field:unsigned char common_flags;	offset:2;	size:1;	signed:0;
+	field:unsigned char common_preempt_count;	offset:3;	size:1;	signed:0;
+	field:int common_pid;	offset:4;	size:4;	signed:1;
+
+	field:__data_loc char[] filename;	offset:8;	size:4;	signed:1;
+
+print fmt: ""%s"", __get_str(filename)
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/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/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 3336045..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;
@@ -296,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/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/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 2ae2d16..abed7f5 100644
--- a/ui/src/core/trace_impl.ts
+++ b/ui/src/core/trace_impl.ts
@@ -50,6 +50,7 @@
 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
@@ -460,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/index.ts b/ui/src/frontend/index.ts
index 67aa000..4c87e4d 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -63,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() {
@@ -226,12 +225,12 @@
   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/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/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/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/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,
+        ),
     );
   }