Merge "Add table for main thread blocking calls in standard library." into main
diff --git a/Android.bp b/Android.bp
index 27ab5f9..2e5a4e0 100644
--- a/Android.bp
+++ b/Android.bp
@@ -2376,6 +2376,7 @@
         ":perfetto_src_trace_processor_perfetto_sql_intrinsics_table_functions_interface",
         ":perfetto_src_trace_processor_perfetto_sql_intrinsics_table_functions_table_functions",
         ":perfetto_src_trace_processor_sorter_sorter",
+        ":perfetto_src_trace_processor_sqlite_bindings_bindings",
         ":perfetto_src_trace_processor_sqlite_query_constraints",
         ":perfetto_src_trace_processor_sqlite_sqlite",
         ":perfetto_src_trace_processor_storage_minimal",
@@ -12443,6 +12444,11 @@
     ],
 }
 
+// GN: //src/trace_processor/sqlite/bindings:bindings
+filegroup {
+    name: "perfetto_src_trace_processor_sqlite_bindings_bindings",
+}
+
 // GN: //src/trace_processor/sqlite:query_constraints
 filegroup {
     name: "perfetto_src_trace_processor_sqlite_query_constraints",
@@ -14282,6 +14288,7 @@
         ":perfetto_src_trace_processor_rpc_unittests",
         ":perfetto_src_trace_processor_sorter_sorter",
         ":perfetto_src_trace_processor_sorter_unittests",
+        ":perfetto_src_trace_processor_sqlite_bindings_bindings",
         ":perfetto_src_trace_processor_sqlite_query_constraints",
         ":perfetto_src_trace_processor_sqlite_sqlite",
         ":perfetto_src_trace_processor_sqlite_unittests",
@@ -14996,6 +15003,7 @@
         ":perfetto_src_trace_processor_rpc_rpc",
         ":perfetto_src_trace_processor_rpc_stdiod",
         ":perfetto_src_trace_processor_sorter_sorter",
+        ":perfetto_src_trace_processor_sqlite_bindings_bindings",
         ":perfetto_src_trace_processor_sqlite_query_constraints",
         ":perfetto_src_trace_processor_sqlite_sqlite",
         ":perfetto_src_trace_processor_storage_minimal",
@@ -15124,6 +15132,204 @@
     },
 }
 
+// GN: //src/trace_redaction:trace_redactor
+cc_binary {
+    name: "trace_redactor",
+    srcs: [
+        ":perfetto_base_default_platform",
+        ":perfetto_include_perfetto_base_base",
+        ":perfetto_include_perfetto_ext_base_base",
+        ":perfetto_include_perfetto_ext_trace_processor_importers_memory_tracker_memory_tracker",
+        ":perfetto_include_perfetto_protozero_protozero",
+        ":perfetto_include_perfetto_public_abi_base",
+        ":perfetto_include_perfetto_public_base",
+        ":perfetto_include_perfetto_public_protozero",
+        ":perfetto_include_perfetto_trace_processor_basic_types",
+        ":perfetto_include_perfetto_trace_processor_storage",
+        ":perfetto_include_perfetto_trace_processor_trace_processor",
+        ":perfetto_protos_perfetto_common_cpp_gen",
+        ":perfetto_protos_perfetto_common_zero_gen",
+        ":perfetto_protos_perfetto_config_android_cpp_gen",
+        ":perfetto_protos_perfetto_config_android_zero_gen",
+        ":perfetto_protos_perfetto_config_cpp_gen",
+        ":perfetto_protos_perfetto_config_ftrace_cpp_gen",
+        ":perfetto_protos_perfetto_config_ftrace_zero_gen",
+        ":perfetto_protos_perfetto_config_gpu_cpp_gen",
+        ":perfetto_protos_perfetto_config_gpu_zero_gen",
+        ":perfetto_protos_perfetto_config_inode_file_cpp_gen",
+        ":perfetto_protos_perfetto_config_inode_file_zero_gen",
+        ":perfetto_protos_perfetto_config_interceptors_cpp_gen",
+        ":perfetto_protos_perfetto_config_interceptors_zero_gen",
+        ":perfetto_protos_perfetto_config_power_cpp_gen",
+        ":perfetto_protos_perfetto_config_power_zero_gen",
+        ":perfetto_protos_perfetto_config_process_stats_cpp_gen",
+        ":perfetto_protos_perfetto_config_process_stats_zero_gen",
+        ":perfetto_protos_perfetto_config_profiling_cpp_gen",
+        ":perfetto_protos_perfetto_config_profiling_zero_gen",
+        ":perfetto_protos_perfetto_config_statsd_cpp_gen",
+        ":perfetto_protos_perfetto_config_statsd_zero_gen",
+        ":perfetto_protos_perfetto_config_sys_stats_cpp_gen",
+        ":perfetto_protos_perfetto_config_sys_stats_zero_gen",
+        ":perfetto_protos_perfetto_config_system_info_cpp_gen",
+        ":perfetto_protos_perfetto_config_system_info_zero_gen",
+        ":perfetto_protos_perfetto_config_track_event_cpp_gen",
+        ":perfetto_protos_perfetto_config_track_event_zero_gen",
+        ":perfetto_protos_perfetto_config_zero_gen",
+        ":perfetto_protos_perfetto_trace_android_cpp_gen",
+        ":perfetto_protos_perfetto_trace_android_zero_gen",
+        ":perfetto_protos_perfetto_trace_chrome_cpp_gen",
+        ":perfetto_protos_perfetto_trace_chrome_zero_gen",
+        ":perfetto_protos_perfetto_trace_etw_cpp_gen",
+        ":perfetto_protos_perfetto_trace_etw_zero_gen",
+        ":perfetto_protos_perfetto_trace_filesystem_cpp_gen",
+        ":perfetto_protos_perfetto_trace_filesystem_zero_gen",
+        ":perfetto_protos_perfetto_trace_ftrace_cpp_gen",
+        ":perfetto_protos_perfetto_trace_ftrace_zero_gen",
+        ":perfetto_protos_perfetto_trace_gpu_cpp_gen",
+        ":perfetto_protos_perfetto_trace_gpu_zero_gen",
+        ":perfetto_protos_perfetto_trace_interned_data_cpp_gen",
+        ":perfetto_protos_perfetto_trace_interned_data_zero_gen",
+        ":perfetto_protos_perfetto_trace_minimal_cpp_gen",
+        ":perfetto_protos_perfetto_trace_minimal_zero_gen",
+        ":perfetto_protos_perfetto_trace_non_minimal_cpp_gen",
+        ":perfetto_protos_perfetto_trace_non_minimal_zero_gen",
+        ":perfetto_protos_perfetto_trace_perfetto_cpp_gen",
+        ":perfetto_protos_perfetto_trace_perfetto_zero_gen",
+        ":perfetto_protos_perfetto_trace_power_cpp_gen",
+        ":perfetto_protos_perfetto_trace_power_zero_gen",
+        ":perfetto_protos_perfetto_trace_processor_zero_gen",
+        ":perfetto_protos_perfetto_trace_profiling_cpp_gen",
+        ":perfetto_protos_perfetto_trace_profiling_zero_gen",
+        ":perfetto_protos_perfetto_trace_ps_cpp_gen",
+        ":perfetto_protos_perfetto_trace_ps_zero_gen",
+        ":perfetto_protos_perfetto_trace_statsd_cpp_gen",
+        ":perfetto_protos_perfetto_trace_statsd_zero_gen",
+        ":perfetto_protos_perfetto_trace_sys_stats_cpp_gen",
+        ":perfetto_protos_perfetto_trace_sys_stats_zero_gen",
+        ":perfetto_protos_perfetto_trace_system_info_cpp_gen",
+        ":perfetto_protos_perfetto_trace_system_info_zero_gen",
+        ":perfetto_protos_perfetto_trace_track_event_cpp_gen",
+        ":perfetto_protos_perfetto_trace_track_event_zero_gen",
+        ":perfetto_protos_perfetto_trace_translation_cpp_gen",
+        ":perfetto_protos_perfetto_trace_translation_zero_gen",
+        ":perfetto_src_base_base",
+        ":perfetto_src_protozero_protozero",
+        ":perfetto_src_trace_processor_containers_containers",
+        ":perfetto_src_trace_processor_db_column_column",
+        ":perfetto_src_trace_processor_db_minimal",
+        ":perfetto_src_trace_processor_importers_common_common",
+        ":perfetto_src_trace_processor_importers_common_parser_types",
+        ":perfetto_src_trace_processor_importers_common_trace_parser_hdr",
+        ":perfetto_src_trace_processor_importers_ftrace_minimal",
+        ":perfetto_src_trace_processor_importers_fuchsia_fuchsia_record",
+        ":perfetto_src_trace_processor_importers_json_minimal",
+        ":perfetto_src_trace_processor_importers_memory_tracker_graph_processor",
+        ":perfetto_src_trace_processor_importers_proto_minimal",
+        ":perfetto_src_trace_processor_importers_proto_packet_sequence_state_generation_hdr",
+        ":perfetto_src_trace_processor_importers_proto_proto_importer_module",
+        ":perfetto_src_trace_processor_importers_systrace_systrace_line",
+        ":perfetto_src_trace_processor_metatrace",
+        ":perfetto_src_trace_processor_sorter_sorter",
+        ":perfetto_src_trace_processor_storage_minimal",
+        ":perfetto_src_trace_processor_storage_storage",
+        ":perfetto_src_trace_processor_tables_tables",
+        ":perfetto_src_trace_processor_types_types",
+        ":perfetto_src_trace_processor_util_build_id",
+        ":perfetto_src_trace_processor_util_bump_allocator",
+        ":perfetto_src_trace_processor_util_descriptors",
+        ":perfetto_src_trace_processor_util_glob",
+        ":perfetto_src_trace_processor_util_gzip",
+        ":perfetto_src_trace_processor_util_interned_message_view",
+        ":perfetto_src_trace_processor_util_profiler_util",
+        ":perfetto_src_trace_processor_util_proto_to_args_parser",
+        ":perfetto_src_trace_processor_util_protozero_to_text",
+        ":perfetto_src_trace_processor_util_regex",
+        ":perfetto_src_trace_processor_util_util",
+        ":perfetto_src_trace_redaction_trace_redaction",
+        "src/trace_redaction/main.cc",
+    ],
+    shared_libs: [
+        "liblog",
+        "libz",
+    ],
+    generated_headers: [
+        "perfetto_protos_perfetto_common_cpp_gen_headers",
+        "perfetto_protos_perfetto_common_zero_gen_headers",
+        "perfetto_protos_perfetto_config_android_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_android_zero_gen_headers",
+        "perfetto_protos_perfetto_config_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_ftrace_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_ftrace_zero_gen_headers",
+        "perfetto_protos_perfetto_config_gpu_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_gpu_zero_gen_headers",
+        "perfetto_protos_perfetto_config_inode_file_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_inode_file_zero_gen_headers",
+        "perfetto_protos_perfetto_config_interceptors_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_interceptors_zero_gen_headers",
+        "perfetto_protos_perfetto_config_power_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_power_zero_gen_headers",
+        "perfetto_protos_perfetto_config_process_stats_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_process_stats_zero_gen_headers",
+        "perfetto_protos_perfetto_config_profiling_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_profiling_zero_gen_headers",
+        "perfetto_protos_perfetto_config_statsd_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_statsd_zero_gen_headers",
+        "perfetto_protos_perfetto_config_sys_stats_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_sys_stats_zero_gen_headers",
+        "perfetto_protos_perfetto_config_system_info_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_system_info_zero_gen_headers",
+        "perfetto_protos_perfetto_config_track_event_cpp_gen_headers",
+        "perfetto_protos_perfetto_config_track_event_zero_gen_headers",
+        "perfetto_protos_perfetto_config_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_android_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_android_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_chrome_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_chrome_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_etw_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_etw_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_filesystem_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_filesystem_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_ftrace_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_ftrace_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_gpu_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_gpu_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_interned_data_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_interned_data_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_minimal_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_minimal_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_non_minimal_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_non_minimal_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_perfetto_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_perfetto_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_power_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_power_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_processor_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_profiling_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_profiling_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_ps_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_ps_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_statsd_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_statsd_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_sys_stats_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_sys_stats_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_system_info_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_system_info_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_track_event_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_track_event_zero_gen_headers",
+        "perfetto_protos_perfetto_trace_translation_cpp_gen_headers",
+        "perfetto_protos_perfetto_trace_translation_zero_gen_headers",
+        "perfetto_src_trace_processor_importers_proto_gen_cc_chrome_track_event_descriptor",
+        "perfetto_src_trace_processor_importers_proto_gen_cc_track_event_descriptor",
+        "perfetto_src_trace_processor_tables_tables_python",
+    ],
+    defaults: [
+        "perfetto_defaults",
+    ],
+    cflags: [
+        "-DZLIB_IMPLEMENTATION",
+    ],
+}
+
 // GN: //src/traceconv:traceconv
 cc_binary_host {
     name: "traceconv",
@@ -15230,6 +15436,7 @@
         ":perfetto_src_trace_processor_perfetto_sql_intrinsics_table_functions_interface",
         ":perfetto_src_trace_processor_perfetto_sql_intrinsics_table_functions_table_functions",
         ":perfetto_src_trace_processor_sorter_sorter",
+        ":perfetto_src_trace_processor_sqlite_bindings_bindings",
         ":perfetto_src_trace_processor_sqlite_query_constraints",
         ":perfetto_src_trace_processor_sqlite_sqlite",
         ":perfetto_src_trace_processor_storage_minimal",
diff --git a/BUILD b/BUILD
index 6767763..ce69dec 100644
--- a/BUILD
+++ b/BUILD
@@ -259,6 +259,7 @@
         ":src_trace_processor_perfetto_sql_intrinsics_table_functions_tables",
         ":src_trace_processor_rpc_rpc",
         ":src_trace_processor_sorter_sorter",
+        ":src_trace_processor_sqlite_bindings_bindings",
         ":src_trace_processor_sqlite_query_constraints",
         ":src_trace_processor_sqlite_sqlite",
         ":src_trace_processor_storage_minimal",
@@ -2636,6 +2637,17 @@
     ],
 )
 
+# GN target: //src/trace_processor/sqlite/bindings:bindings
+perfetto_filegroup(
+    name = "src_trace_processor_sqlite_bindings_bindings",
+    srcs = [
+        "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h",
+        "src/trace_processor/sqlite/bindings/sqlite_module.h",
+        "src/trace_processor/sqlite/bindings/sqlite_result.h",
+        "src/trace_processor/sqlite/bindings/sqlite_window_function.h",
+    ],
+)
+
 # GN target: //src/trace_processor/sqlite:query_constraints
 perfetto_filegroup(
     name = "src_trace_processor_sqlite_query_constraints",
@@ -2659,14 +2671,12 @@
         "src/trace_processor/sqlite/sql_stats_table.h",
         "src/trace_processor/sqlite/sqlite_engine.cc",
         "src/trace_processor/sqlite/sqlite_engine.h",
-        "src/trace_processor/sqlite/sqlite_result.h",
         "src/trace_processor/sqlite/sqlite_table.cc",
         "src/trace_processor/sqlite/sqlite_table.h",
         "src/trace_processor/sqlite/sqlite_tokenizer.cc",
         "src/trace_processor/sqlite/sqlite_tokenizer.h",
         "src/trace_processor/sqlite/sqlite_utils.cc",
         "src/trace_processor/sqlite/sqlite_utils.h",
-        "src/trace_processor/sqlite/sqlite_window_function.h",
         "src/trace_processor/sqlite/stats_table.cc",
         "src/trace_processor/sqlite/stats_table.h",
     ],
@@ -5685,6 +5695,7 @@
         ":src_trace_processor_perfetto_sql_intrinsics_table_functions_table_functions",
         ":src_trace_processor_perfetto_sql_intrinsics_table_functions_tables",
         ":src_trace_processor_sorter_sorter",
+        ":src_trace_processor_sqlite_bindings_bindings",
         ":src_trace_processor_sqlite_query_constraints",
         ":src_trace_processor_sqlite_sqlite",
         ":src_trace_processor_storage_minimal",
@@ -5856,6 +5867,7 @@
         ":src_trace_processor_rpc_rpc",
         ":src_trace_processor_rpc_stdiod",
         ":src_trace_processor_sorter_sorter",
+        ":src_trace_processor_sqlite_bindings_bindings",
         ":src_trace_processor_sqlite_query_constraints",
         ":src_trace_processor_sqlite_sqlite",
         ":src_trace_processor_storage_minimal",
@@ -6079,6 +6091,7 @@
         ":src_trace_processor_perfetto_sql_intrinsics_table_functions_table_functions",
         ":src_trace_processor_perfetto_sql_intrinsics_table_functions_tables",
         ":src_trace_processor_sorter_sorter",
+        ":src_trace_processor_sqlite_bindings_bindings",
         ":src_trace_processor_sqlite_query_constraints",
         ":src_trace_processor_sqlite_sqlite",
         ":src_trace_processor_storage_minimal",
diff --git a/src/trace_processor/importers/perf/perf_data_parser.cc b/src/trace_processor/importers/perf/perf_data_parser.cc
index bb79209..71912f2 100644
--- a/src/trace_processor/importers/perf/perf_data_parser.cc
+++ b/src/trace_processor/importers/perf/perf_data_parser.cc
@@ -59,8 +59,9 @@
 
   // First instruction pointer in the callchain should be from kernel space, so
   // it shouldn't be available in mappings.
+  UniquePid upid = context_->process_tracker->GetOrCreateProcess(*sample.pid);
   if (context_->mapping_tracker->FindUserMappingForAddress(
-          *sample.pid, sample.callchain.front())) {
+          upid, sample.callchain.front())) {
     context_->storage->IncrementStats(stats::perf_samples_skipped);
     return;
   }
@@ -74,7 +75,7 @@
   for (uint32_t i = 1; i < sample.callchain.size(); i++) {
     UserMemoryMapping* mapping =
         context_->mapping_tracker->FindUserMappingForAddress(
-            *sample.pid, sample.callchain[i]);
+            upid, sample.callchain[i]);
     if (!mapping) {
       context_->storage->IncrementStats(stats::perf_samples_skipped);
       return;
@@ -121,7 +122,6 @@
   }
   if (sample.tid) {
     auto utid = context_->process_tracker->GetOrCreateThread(*sample.tid);
-    context_->process_tracker->GetOrCreateProcess(*sample.pid);
     perf_sample_row.utid = utid;
   }
   context_->storage->mutable_perf_sample_table()->Insert(perf_sample_row);
diff --git a/src/trace_processor/importers/proto/system_probes_parser.cc b/src/trace_processor/importers/proto/system_probes_parser.cc
index 2654fdb..8812674 100644
--- a/src/trace_processor/importers/proto/system_probes_parser.cc
+++ b/src/trace_processor/importers/proto/system_probes_parser.cc
@@ -107,6 +107,10 @@
 SystemProbesParser::SystemProbesParser(TraceProcessorContext* context)
     : context_(context),
       utid_name_id_(context->storage->InternString("utid")),
+      ns_unit_id_(context->storage->InternString("ns")),
+      bytes_unit_id_(context->storage->InternString("bytes")),
+      available_chunks_unit_id_(
+          context->storage->InternString("available chunks")),
       num_forks_name_id_(context->storage->InternString("num_forks")),
       num_irq_total_name_id_(context->storage->InternString("num_irq_total")),
       num_softirq_total_name_id_(
@@ -208,6 +212,8 @@
     context_->event_tracker->PushCounter(ts, value, track);
   };
 
+  // TODO(rsavitski): with the UI now supporting rate mode for counter tracks,
+  // this is likely redundant.
   auto calculate_throughput = [](double amount, int64_t diff) {
     return diff == 0 ? 0 : amount * MS_PER_SEC / static_cast<double>(diff);
   };
@@ -277,9 +283,10 @@
     }
     // /proc/meminfo counters are in kB, convert to bytes
     TrackId track = context_->track_tracker->InternGlobalCounterTrack(
-        TrackTracker::Group::kMemory, meminfo_strs_id_[key]);
+        TrackTracker::Group::kMemory, meminfo_strs_id_[key], {},
+        bytes_unit_id_);
     context_->event_tracker->PushCounter(
-        ts, static_cast<double>(mi.value()) * 1024., track);
+        ts, static_cast<double>(mi.value()) * 1024, track);
   }
 
   for (auto it = sys_stats.devfreq(); it; ++it) {
@@ -402,20 +409,23 @@
         ts, static_cast<double>(sys_stats.num_softirq_total()), track);
   }
 
+  // Fragmentation of the kernel binary buddy memory allocator.
+  // See /proc/buddyinfo in `man 5 proc`.
   for (auto it = sys_stats.buddy_info(); it; ++it) {
     protos::pbzero::SysStats::BuddyInfo::Decoder bi(*it);
     int order = 0;
     for (auto order_it = bi.order_pages(); order_it; ++order_it) {
       std::string node = bi.node().ToStdString();
       std::string zone = bi.zone().ToStdString();
-      uint32_t size_kb =
+      uint32_t chunk_size_kb =
           static_cast<uint32_t>(((1 << order) * page_size_) / 1024);
       base::StackString<255> counter_name("mem.buddyinfo[%s][%s][%u kB]",
-                                          node.c_str(), zone.c_str(), size_kb);
+                                          node.c_str(), zone.c_str(),
+                                          chunk_size_kb);
       StringId name =
           context_->storage->InternString(counter_name.string_view());
       TrackId track = context_->track_tracker->InternGlobalCounterTrack(
-          TrackTracker::Group::kMemory, name);
+          TrackTracker::Group::kMemory, name, {}, available_chunks_unit_id_);
       context_->event_tracker->PushCounter(ts, static_cast<double>(*order_it),
                                            track);
       order++;
@@ -426,6 +436,8 @@
     ParseDiskStats(ts, *it);
   }
 
+  // Pressure Stall Information. See
+  // https://docs.kernel.org/accounting/psi.html.
   for (auto it = sys_stats.psi(); it; ++it) {
     protos::pbzero::SysStats::PsiSample::Decoder psi(*it);
 
@@ -436,11 +448,12 @@
       continue;
     }
 
+    // Unit = total blocked time on this resource in nanoseconds.
     // TODO(b/315152880): Consider moving psi entries for cpu/io/memory into
     // groups specific to that resource (e.g., `Group::kMemory`).
     TrackId track = context_->track_tracker->InternGlobalCounterTrack(
         TrackTracker::Group::kDeviceState,
-        sys_stats_psi_resource_names_[resource]);
+        sys_stats_psi_resource_names_[resource], {}, ns_unit_id_);
     context_->event_tracker->PushCounter(
         ts, static_cast<double>(psi.total_ns()), track);
   }
diff --git a/src/trace_processor/importers/proto/system_probes_parser.h b/src/trace_processor/importers/proto/system_probes_parser.h
index 776b2c6..a54beb7 100644
--- a/src/trace_processor/importers/proto/system_probes_parser.h
+++ b/src/trace_processor/importers/proto/system_probes_parser.h
@@ -50,6 +50,10 @@
   TraceProcessorContext* const context_;
 
   const StringId utid_name_id_;
+  const StringId ns_unit_id_;
+  const StringId bytes_unit_id_;
+  const StringId available_chunks_unit_id_;
+
   const StringId num_forks_name_id_;
   const StringId num_irq_total_name_id_;
   const StringId num_softirq_total_name_id_;
diff --git a/src/trace_processor/metrics/metrics.cc b/src/trace_processor/metrics/metrics.cc
index d351e0a..ff492f4 100644
--- a/src/trace_processor/metrics/metrics.cc
+++ b/src/trace_processor/metrics/metrics.cc
@@ -518,7 +518,7 @@
   return base::OkStatus();
 }
 
-void RepeatedFieldStep(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
+void RepeatedField::Step(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
   if (argc != 1) {
     sqlite::result::Error(ctx, "RepeatedField: only expected one arg");
     return;
@@ -546,7 +546,7 @@
   }
 }
 
-void RepeatedFieldFinal(sqlite3_context* ctx) {
+void RepeatedField::Final(sqlite3_context* ctx) {
   // Note: we choose the size intentionally to be zero because we don't want to
   // allocate if the Step has never been called.
   auto** builder_ptr_ptr =
diff --git a/src/trace_processor/metrics/metrics.h b/src/trace_processor/metrics/metrics.h
index 7eead1f..3918489 100644
--- a/src/trace_processor/metrics/metrics.h
+++ b/src/trace_processor/metrics/metrics.h
@@ -36,6 +36,7 @@
 #include "perfetto/trace_processor/trace_processor.h"
 #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
 #include "src/trace_processor/perfetto_sql/intrinsics/functions/sql_function.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h"
 #include "src/trace_processor/util/descriptors.h"
 
 #include "protos/perfetto/trace_processor/metrics_impl.pbzero.h"
@@ -194,8 +195,10 @@
 };
 
 // These functions implement the RepeatedField SQL aggregate functions.
-void RepeatedFieldStep(sqlite3_context* ctx, int argc, sqlite3_value** argv);
-void RepeatedFieldFinal(sqlite3_context* ctx);
+struct RepeatedField : public SqliteAggregateFunction {
+  static void Step(sqlite3_context* ctx, int argc, sqlite3_value** argv);
+  static void Final(sqlite3_context* ctx);
+};
 
 base::Status ComputeMetrics(PerfettoSqlEngine*,
                             const std::vector<std::string>& metrics_to_compute,
diff --git a/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.cc b/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.cc
index 6d7f965..8959989 100644
--- a/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.cc
+++ b/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.cc
@@ -182,8 +182,8 @@
   }
 
   engine_->RegisterVirtualTableModule<RuntimeTableFunction>(
-      "runtime_table_function", this, SqliteTable::TableType::kExplicitCreate,
-      false);
+      "runtime_table_function", this,
+      SqliteTableLegacy::TableType::kExplicitCreate, false);
   auto context = std::make_unique<DbSqliteTable::Context>(
       query_cache_.get(),
       [this](const std::string& name) {
@@ -197,7 +197,7 @@
       });
   engine_->RegisterVirtualTableModule<DbSqliteTable>(
       "runtime_table", std::move(context),
-      SqliteTable::TableType::kExplicitCreate, false);
+      SqliteTableLegacy::TableType::kExplicitCreate, false);
 }
 
 PerfettoSqlEngine::~PerfettoSqlEngine() {
@@ -215,7 +215,7 @@
       query_cache_.get(), &table, std::move(schema));
   static_tables_.Insert(table_name, &table);
   engine_->RegisterVirtualTableModule<DbSqliteTable>(
-      table_name, std::move(context), SqliteTable::kEponymousOnly, false);
+      table_name, std::move(context), SqliteTableLegacy::kEponymousOnly, false);
 
   // Register virtual tables into an internal 'perfetto_tables' table.
   // This is used for iterating through all the tables during a database
@@ -237,7 +237,7 @@
   auto context = std::make_unique<DbSqliteTable::Context>(query_cache_.get(),
                                                           std::move(fn));
   engine_->RegisterVirtualTableModule<DbSqliteTable>(
-      table_name, std::move(context), SqliteTable::kEponymousOnly, false);
+      table_name, std::move(context), SqliteTableLegacy::kEponymousOnly, false);
 }
 
 base::StatusOr<PerfettoSqlEngine::ExecutionStats> PerfettoSqlEngine::Execute(
diff --git a/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h b/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h
index f11d04e..8fe5425 100644
--- a/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h
+++ b/src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h
@@ -38,12 +38,13 @@
 #include "src/trace_processor/perfetto_sql/engine/runtime_table_function.h"
 #include "src/trace_processor/perfetto_sql/intrinsics/functions/sql_function.h"
 #include "src/trace_processor/perfetto_sql/intrinsics/table_functions/static_table_function.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_window_function.h"
 #include "src/trace_processor/sqlite/query_cache.h"
 #include "src/trace_processor/sqlite/sql_source.h"
 #include "src/trace_processor/sqlite/sqlite_engine.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
 #include "src/trace_processor/sqlite/sqlite_utils.h"
-#include "src/trace_processor/sqlite/sqlite_window_function.h"
 #include "src/trace_processor/util/sql_argument.h"
 #include "src/trace_processor/util/sql_modules.h"
 
@@ -88,14 +89,15 @@
   //
   // The format of the function is given by the |SqlFunction|.
   //
-  // |name|:        name of the function in SQL.
-  // |argc|:        number of arguments for this function. This can be -1 if
-  //                the number of arguments is variable.
-  // |ctx|:         context object for the function (see SqlFunction::Run);
-  //                this object *must* outlive the function so should likely be
-  //                either static or scoped to the lifetime of TraceProcessor.
-  // |determistic|: whether this function has deterministic output given the
-  //                same set of arguments.
+  // |name|:          name of the function in SQL.
+  // |argc|:          number of arguments for this function. This can be -1 if
+  //                  the number of arguments is variable.
+  // |ctx|:           context object for the function (see SqlFunction::Run);
+  //                  this object *must* outlive the function so should likely
+  //                  be either static or scoped to the lifetime of
+  //                  TraceProcessor.
+  // |deterministic|: whether this function has deterministic output given the
+  //                  same set of arguments.
   template <typename Function = SqlFunction>
   base::Status RegisterStaticFunction(const char* name,
                                       int argc,
@@ -115,18 +117,36 @@
       std::unique_ptr<typename Function::Context> ctx,
       bool deterministic = true);
 
+  // Registers a trace processor C++ aggregate function to be runnable from SQL.
+  //
+  // The format of the function is given by the |SqliteAggregateFunction|.
+  //
+  // |name|:          name of the function in SQL
+  // |argc|:          number of arguments for this function. This can be -1 if
+  //                  the number of arguments is variable.
+  // |ctx|:           context object for the function; this object *must*
+  //                  outlive the function so should likely be either static or
+  //                  scoped to the lifetime of TraceProcessor.
+  // |deterministic|: whether this function has deterministic output given the
+  //                  same set of arguments.
+  template <typename Function = SqliteAggregateFunction>
+  base::Status RegisterSqliteAggregateFunction(const char* name,
+                                               int argc,
+                                               typename Function::Context* ctx,
+                                               bool deterministic = true);
+
   // Registers a trace processor C++ window function to be runnable from SQL.
   //
   // The format of the function is given by the |SqliteWindowFunction|.
   //
-  // |name|:        name of the function in SQL.
-  // |argc|:        number of arguments for this function. This can be -1 if
-  //                the number of arguments is variable.
-  // |ctx|:         context object for the function (see SqlFunction::Run);
-  //                this object *must* outlive the function so should likely be
-  //                either static or scoped to the lifetime of TraceProcessor.
-  // |determistic|: whether this function has deterministic output given the
-  //                same set of arguments.
+  // |name|:          name of the function in SQL.
+  // |argc|:          number of arguments for this function. This can be -1 if
+  //                  the number of arguments is variable.
+  // |ctx|:           context object for the function; this object *must*
+  //                  outlive the function so should likely be either static or
+  //                  scoped to the lifetime of TraceProcessor.
+  // |deterministic|: whether this function has deterministic output given the
+  //                  same set of arguments.
   template <typename Function = SqliteWindowFunction>
   base::Status RegisterSqliteWindowFunction(const char* name,
                                             int argc,
@@ -191,8 +211,9 @@
 
     // The missing objects from the above query are static functions, runtime
     // functions and macros. Add those in now.
-    return query_count + static_function_count_ + runtime_function_count_ +
-           static_window_function_count_ + macros_.size();
+    return query_count + static_function_count_ +
+           static_window_function_count_ + static_aggregate_function_count_ +
+           runtime_function_count_ + macros_.size();
   }
 
   // Find RuntimeTable registered with engine with provided name.
@@ -252,8 +273,9 @@
   StringPool* pool_ = nullptr;
 
   uint64_t static_function_count_ = 0;
-  uint64_t runtime_function_count_ = 0;
+  uint64_t static_aggregate_function_count_ = 0;
   uint64_t static_window_function_count_ = 0;
+  uint64_t runtime_function_count_ = 0;
 
   base::FlatHashMap<std::string, std::unique_ptr<RuntimeTableFunction::State>>
       runtime_table_fn_states_;
@@ -336,6 +358,17 @@
 }
 
 template <typename Function>
+base::Status PerfettoSqlEngine::RegisterSqliteAggregateFunction(
+    const char* name,
+    int argc,
+    typename Function::Context* ctx,
+    bool deterministic) {
+  static_aggregate_function_count_++;
+  return engine_->RegisterAggregateFunction(
+      name, argc, Function::Step, Function::Final, ctx, nullptr, deterministic);
+}
+
+template <typename Function>
 base::Status PerfettoSqlEngine::RegisterSqliteWindowFunction(
     const char* name,
     int argc,
diff --git a/src/trace_processor/perfetto_sql/engine/runtime_table_function.cc b/src/trace_processor/perfetto_sql/engine/runtime_table_function.cc
index 1eaab01..3d0af93 100644
--- a/src/trace_processor/perfetto_sql/engine/runtime_table_function.cc
+++ b/src/trace_processor/perfetto_sql/engine/runtime_table_function.cc
@@ -20,7 +20,7 @@
 #include <utility>
 
 #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/util/status_macros.h"
 
 namespace perfetto {
@@ -52,7 +52,7 @@
   return base::OkStatus();
 }
 
-SqliteTable::Schema RuntimeTableFunction::CreateSchema() {
+SqliteTableLegacy::Schema RuntimeTableFunction::CreateSchema() {
   std::vector<Column> columns;
   for (size_t i = 0; i < state_->return_values.size(); ++i) {
     const auto& ret = state_->return_values[i];
@@ -80,10 +80,11 @@
       Column(columns.size(), "_primary_key", SqlValue::kLong, true));
   primary_keys.emplace_back(columns.size() - 1);
 
-  return SqliteTable::Schema(std::move(columns), std::move(primary_keys));
+  return SqliteTableLegacy::Schema(std::move(columns), std::move(primary_keys));
 }
 
-std::unique_ptr<SqliteTable::BaseCursor> RuntimeTableFunction::CreateCursor() {
+std::unique_ptr<SqliteTableLegacy::BaseCursor>
+RuntimeTableFunction::CreateCursor() {
   return std::unique_ptr<Cursor>(new Cursor(this, state_));
 }
 
@@ -109,7 +110,7 @@
 }
 
 RuntimeTableFunction::Cursor::Cursor(RuntimeTableFunction* table, State* state)
-    : SqliteTable::BaseCursor(table), table_(table), state_(state) {
+    : SqliteTableLegacy::BaseCursor(table), table_(table), state_(state) {
   if (state->reusable_stmt) {
     stmt_ = std::move(state->reusable_stmt);
     state->reusable_stmt = std::nullopt;
diff --git a/src/trace_processor/perfetto_sql/engine/runtime_table_function.h b/src/trace_processor/perfetto_sql/engine/runtime_table_function.h
index 7c92fcc..80edf72 100644
--- a/src/trace_processor/perfetto_sql/engine/runtime_table_function.h
+++ b/src/trace_processor/perfetto_sql/engine/runtime_table_function.h
@@ -27,8 +27,8 @@
 
 class PerfettoSqlEngine;
 
-// The implementation of the SqliteTable interface for table functions defined
-// at runtime using SQL.
+// The implementation of the SqliteTableLegacy interface for table functions
+// defined at runtime using SQL.
 class RuntimeTableFunction final
     : public TypedSqliteTable<RuntimeTableFunction, PerfettoSqlEngine*> {
  public:
@@ -65,7 +65,7 @@
              kPrimaryKeyColumns;
     }
   };
-  class Cursor final : public SqliteTable::BaseCursor {
+  class Cursor final : public SqliteTableLegacy::BaseCursor {
    public:
     explicit Cursor(RuntimeTableFunction* table, State* state);
     ~Cursor() final;
@@ -92,7 +92,7 @@
   ~RuntimeTableFunction() final;
 
   base::Status Init(int argc, const char* const* argv, Schema*) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
+  std::unique_ptr<SqliteTableLegacy::BaseCursor> CreateCursor() final;
   int BestIndex(const QueryConstraints& qc, BestIndexInfo* info) final;
 
  private:
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/layout_functions.cc b/src/trace_processor/perfetto_sql/intrinsics/functions/layout_functions.cc
index 70b3bce..3384376 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/layout_functions.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/layout_functions.cc
@@ -23,9 +23,9 @@
 #include "perfetto/ext/base/status_or.h"
 #include "perfetto/trace_processor/basic_types.h"
 #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_window_function.h"
 #include "src/trace_processor/sqlite/sqlite_utils.h"
-#include "src/trace_processor/sqlite/sqlite_window_function.h"
 #include "src/trace_processor/util/status_macros.h"
 
 namespace perfetto::trace_processor {
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.cc b/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.cc
index d02eaeb..8fda79e 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.cc
@@ -34,6 +34,7 @@
 #include "perfetto/trace_processor/basic_types.h"
 #include "perfetto/trace_processor/status.h"
 #include "protos/perfetto/trace_processor/stack.pbzero.h"
+#include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
 #include "src/trace_processor/sqlite/sqlite_utils.h"
 #include "src/trace_processor/types/trace_processor_context.h"
 #include "src/trace_processor/util/profile_builder.h"
@@ -156,7 +157,9 @@
   std::vector<int64_t> sample_values_;
 };
 
-base::Status Step(sqlite3_context* ctx, size_t argc, sqlite3_value** argv) {
+base::Status StepStatus(sqlite3_context* ctx,
+                        size_t argc,
+                        sqlite3_value** argv) {
   auto** agg_context_ptr = static_cast<AggregateContext**>(
       sqlite3_aggregate_context(ctx, sizeof(AggregateContext*)));
   if (!agg_context_ptr) {
@@ -178,42 +181,39 @@
   return (*agg_context_ptr)->Step(argc, argv);
 }
 
-void StepWrapper(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
-  PERFETTO_CHECK(argc >= 0);
+struct ProfileBuilder {
+  using Context = TraceProcessorContext;
 
-  base::Status status = Step(ctx, static_cast<size_t>(argc), argv);
+  static void Step(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
+    PERFETTO_CHECK(argc >= 0);
 
-  if (!status.ok()) {
-    sqlite::utils::SetError(ctx, kFunctionName, status);
-  }
-}
+    base::Status status = StepStatus(ctx, static_cast<size_t>(argc), argv);
 
-void FinalWrapper(sqlite3_context* ctx) {
-  auto** agg_context_ptr =
-      static_cast<AggregateContext**>(sqlite3_aggregate_context(ctx, 0));
-
-  if (!agg_context_ptr) {
-    return;
+    if (!status.ok()) {
+      sqlite::utils::SetError(ctx, kFunctionName, status);
+    }
   }
 
-  (*agg_context_ptr)->Final(ctx);
+  static void Final(sqlite3_context* ctx) {
+    auto** agg_context_ptr =
+        static_cast<AggregateContext**>(sqlite3_aggregate_context(ctx, 0));
 
-  delete (*agg_context_ptr);
-}
+    if (!agg_context_ptr) {
+      return;
+    }
+
+    (*agg_context_ptr)->Final(ctx);
+
+    delete (*agg_context_ptr);
+  }
+};
 
 }  // namespace
 
-base::Status PprofFunctions::Register(sqlite3* db,
+base::Status PprofFunctions::Register(PerfettoSqlEngine& engine,
                                       TraceProcessorContext* context) {
-  int flags = SQLITE_UTF8 | SQLITE_DETERMINISTIC;
-  int ret =
-      sqlite3_create_function_v2(db, kFunctionName, -1, flags, context, nullptr,
-                                 StepWrapper, FinalWrapper, nullptr);
-  if (ret != SQLITE_OK) {
-    return base::ErrStatus("Unable to register function with name %s",
-                           kFunctionName);
-  }
-  return base::OkStatus();
+  return engine.RegisterSqliteAggregateFunction<ProfileBuilder>(kFunctionName,
+                                                                -1, context);
 }
 
 }  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.h b/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.h
index 24e6d72..ca5b459 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.h
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/pprof_functions.h
@@ -20,17 +20,16 @@
 #include <sqlite3.h>
 
 #include "perfetto/base/status.h"
+#include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
 class TraceProcessorContext;
 
 struct PprofFunctions {
-  static base::Status Register(sqlite3* db, TraceProcessorContext* context);
+  static base::Status Register(PerfettoSqlEngine&, TraceProcessorContext*);
 };
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
 
 #endif  // SRC_TRACE_PROCESSOR_PERFETTO_SQL_INTRINSICS_FUNCTIONS_PPROF_FUNCTIONS_H_
diff --git a/src/trace_processor/perfetto_sql/intrinsics/functions/window_functions.h b/src/trace_processor/perfetto_sql/intrinsics/functions/window_functions.h
index 00de395..c9f606d 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/functions/window_functions.h
+++ b/src/trace_processor/perfetto_sql/intrinsics/functions/window_functions.h
@@ -23,8 +23,8 @@
 
 #include "perfetto/base/logging.h"
 #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_engine.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
-#include "src/trace_processor/sqlite/sqlite_window_function.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_window_function.h"
 
 namespace perfetto::trace_processor {
 
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.cc b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.cc
index 6fe221d..66ffa4b 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.cc
@@ -47,7 +47,7 @@
 }
 
 std::optional<std::string> HasDuplicateColumns(
-    const std::vector<SqliteTable::Column>& cols) {
+    const std::vector<SqliteTableLegacy::Column>& cols) {
   std::set<std::string> names;
   for (const auto& col : cols) {
     if (names.count(col.name()) > 0)
@@ -180,7 +180,7 @@
   if (!status.ok())
     return status;
 
-  std::vector<SqliteTable::Column> cols;
+  std::vector<SqliteTableLegacy::Column> cols;
   // Ensure the shared columns are consistently ordered and are not
   // present twice in the final schema
   cols.emplace_back(Column::kTimestamp, kTsColumnName, SqlValue::Type::kLong);
@@ -217,7 +217,7 @@
 
 void SpanJoinOperatorTable::CreateSchemaColsForDefn(
     const TableDefinition& defn,
-    std::vector<SqliteTable::Column>* cols) {
+    std::vector<SqliteTableLegacy::Column>* cols) {
   for (size_t i = 0; i < defn.columns().size(); i++) {
     const auto& n = defn.columns()[i].name();
     if (IsRequiredColumn(n) || n == defn.partition_col())
@@ -231,7 +231,8 @@
   }
 }
 
-std::unique_ptr<SqliteTable::BaseCursor> SpanJoinOperatorTable::CreateCursor() {
+std::unique_ptr<SqliteTableLegacy::BaseCursor>
+SpanJoinOperatorTable::CreateCursor() {
   return std::unique_ptr<SpanJoinOperatorTable::Cursor>(
       new Cursor(this, engine_));
 }
@@ -331,7 +332,7 @@
         desc.name.c_str());
   }
 
-  std::vector<SqliteTable::Column> cols;
+  std::vector<SqliteTableLegacy::Column> cols;
   RETURN_IF_ERROR(sqlite::utils::GetColumnsForTable(
       engine_->sqlite_engine()->db(), desc.name, cols));
 
@@ -396,7 +397,7 @@
 
 SpanJoinOperatorTable::Cursor::Cursor(SpanJoinOperatorTable* table,
                                       PerfettoSqlEngine* engine)
-    : SqliteTable::BaseCursor(table),
+    : SqliteTableLegacy::BaseCursor(table),
       t1_(table, &table->t1_defn_, engine),
       t2_(table, &table->t2_defn_, engine),
       table_(table) {}
@@ -783,7 +784,7 @@
 std::string SpanJoinOperatorTable::Query::CreateSqlQuery(
     const std::vector<std::string>& cs) const {
   std::vector<std::string> col_names;
-  for (const SqliteTable::Column& c : defn_->columns()) {
+  for (const SqliteTableLegacy::Column& c : defn_->columns()) {
     col_names.push_back("`" + c.name() + "`");
   }
 
@@ -833,7 +834,7 @@
 SpanJoinOperatorTable::TableDefinition::TableDefinition(
     std::string name,
     std::string partition_col,
-    std::vector<SqliteTable::Column> cols,
+    std::vector<SqliteTableLegacy::Column> cols,
     EmitShadowType emit_shadow_type,
     uint32_t ts_idx,
     uint32_t dur_idx,
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.h b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.h
index 2c663c8..c734b7f 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.h
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator.h
@@ -98,7 +98,7 @@
 
     TableDefinition(std::string name,
                     std::string partition_col,
-                    std::vector<SqliteTable::Column> cols,
+                    std::vector<SqliteTableLegacy::Column> cols,
                     EmitShadowType emit_shadow_type,
                     uint32_t ts_idx,
                     uint32_t dur_idx,
@@ -120,7 +120,9 @@
 
     const std::string& name() const { return name_; }
     const std::string& partition_col() const { return partition_col_; }
-    const std::vector<SqliteTable::Column>& columns() const { return cols_; }
+    const std::vector<SqliteTableLegacy::Column>& columns() const {
+      return cols_;
+    }
 
     uint32_t ts_idx() const { return ts_idx_; }
     uint32_t dur_idx() const { return dur_idx_; }
@@ -131,7 +133,7 @@
 
     std::string name_;
     std::string partition_col_;
-    std::vector<SqliteTable::Column> cols_;
+    std::vector<SqliteTableLegacy::Column> cols_;
 
     uint32_t ts_idx_ = std::numeric_limits<uint32_t>::max();
     uint32_t dur_idx_ = std::numeric_limits<uint32_t>::max();
@@ -323,7 +325,7 @@
   };
 
   // Base class for a cursor on the span table.
-  class Cursor final : public SqliteTable::BaseCursor {
+  class Cursor final : public SqliteTableLegacy::BaseCursor {
    public:
     Cursor(SpanJoinOperatorTable*, PerfettoSqlEngine*);
     ~Cursor() final;
@@ -361,8 +363,8 @@
   ~SpanJoinOperatorTable() final;
 
   // Table implementation.
-  util::Status Init(int, const char* const*, SqliteTable::Schema*) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
+  util::Status Init(int, const char* const*, SqliteTableLegacy::Schema*) final;
+  std::unique_ptr<SqliteTableLegacy::BaseCursor> CreateCursor() final;
   int BestIndex(const QueryConstraints& qc, BestIndexInfo* info) final;
   int FindFunction(const char* name, FindFunctionFn* fn, void** args) final;
 
@@ -430,7 +432,7 @@
                                           int global_column);
 
   void CreateSchemaColsForDefn(const TableDefinition& defn,
-                               std::vector<SqliteTable::Column>* cols);
+                               std::vector<SqliteTableLegacy::Column>* cols);
 
   TableDefinition t1_defn_;
   TableDefinition t2_defn_;
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator_unittest.cc b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator_unittest.cc
index bcd3ea9..2695e43 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator_unittest.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/span_join_operator_unittest.cc
@@ -28,10 +28,11 @@
  public:
   SpanJoinOperatorTableTest() {
     engine_.sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
-        "span_join", &engine_, SqliteTable::TableType::kExplicitCreate, false);
-    engine_.sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
-        "span_left_join", &engine_, SqliteTable::TableType::kExplicitCreate,
+        "span_join", &engine_, SqliteTableLegacy::TableType::kExplicitCreate,
         false);
+    engine_.sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
+        "span_left_join", &engine_,
+        SqliteTableLegacy::TableType::kExplicitCreate, false);
   }
 
   void PrepareValidStatement(const std::string& sql) {
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.cc b/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.cc
index 41c2841..eb31bb3 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.cc
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.cc
@@ -16,142 +16,204 @@
 
 #include "src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.h"
 
-#include "perfetto/base/status.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
+#include <sqlite3.h>
+#include <cstdint>
+#include <memory>
+
+#include "perfetto/base/logging.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/sqlite/sqlite_utils.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
-WindowOperatorTable::WindowOperatorTable(sqlite3*, const TraceStorage*) {}
-WindowOperatorTable::~WindowOperatorTable() = default;
-
-base::Status WindowOperatorTable::Init(int,
-                                       const char* const*,
-                                       Schema* schema) {
-  const bool kHidden = true;
-  *schema = Schema(
-      {
-          // These are the operator columns:
-          SqliteTable::Column(Column::kRowId, "rowid", SqlValue::Type::kLong,
-                              kHidden),
-          SqliteTable::Column(Column::kQuantum, "quantum",
-                              SqlValue::Type::kLong, kHidden),
-          SqliteTable::Column(Column::kWindowStart, "window_start",
-                              SqlValue::Type::kLong, kHidden),
-          SqliteTable::Column(Column::kWindowDur, "window_dur",
-                              SqlValue::Type::kLong, kHidden),
-          // These are the ouput columns:
-          SqliteTable::Column(Column::kTs, "ts", SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kDuration, "dur", SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kQuantumTs, "quantum_ts",
-                              SqlValue::Type::kLong),
-      },
-      {Column::kRowId});
-  return base::OkStatus();
+namespace {
+constexpr char kSchema[] = R"(
+    CREATE TABLE x(
+      rowid BIGINT HIDDEN,
+      quantum BIGINT HIDDEN,
+      window_start BIGINT HIDDEN,
+      window_dur BIGINT HIDDEN,
+      ts BIGINT,
+      dur BIGINT,
+      quantum_ts BIGINT,
+      PRIMARY KEY(rowid)
+    ) WITHOUT ROWID
+  )";
 }
 
-std::unique_ptr<SqliteTable::BaseCursor> WindowOperatorTable::CreateCursor() {
-  return std::unique_ptr<SqliteTable::BaseCursor>(new Cursor(this));
-}
+int WindowOperatorModule::Create(sqlite3* db,
+                                 void* raw_ctx,
+                                 int argc,
+                                 const char* const* argv,
+                                 sqlite3_vtab** vtab,
+                                 char**) {
+  PERFETTO_CHECK(argc == 3);
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
+  }
+  auto* ctx = GetContext(raw_ctx);
+  auto it_and_inserted = ctx->state_by_name.Insert(argv[2], nullptr);
+  PERFETTO_CHECK(
+      it_and_inserted.second ||
+      (it_and_inserted.first && it_and_inserted.first->get()->disconnected));
+  *it_and_inserted.first = std::make_unique<State>();
 
-int WindowOperatorTable::BestIndex(const QueryConstraints&, BestIndexInfo*) {
+  std::unique_ptr<Vtab> res = std::make_unique<Vtab>();
+  res->context = ctx;
+  res->name = argv[2];
+  res->state = it_and_inserted.first->get();
+  *vtab = res.release();
   return SQLITE_OK;
 }
 
-base::Status WindowOperatorTable::ModifyConstraints(QueryConstraints* qc) {
-  // Remove ordering on timestamp if it is the only ordering as we are already
-  // sorted on TS. This makes span joining significantly faster.
-  const auto& ob = qc->order_by();
-  if (ob.size() == 1 && ob[0].iColumn == Column::kTs && !ob[0].desc) {
-    qc->mutable_order_by()->clear();
-  }
-  return base::OkStatus();
+int WindowOperatorModule::Destroy(sqlite3_vtab* vtab) {
+  auto* tab = GetVtab(vtab);
+  PERFETTO_CHECK(tab->context->state_by_name.Erase(tab->name));
+  delete tab;
+  return SQLITE_OK;
 }
 
-base::Status WindowOperatorTable::Update(int argc,
-                                         sqlite3_value** argv,
-                                         sqlite3_int64*) {
-  // We only support updates to ts and dur. Disallow deletes (argc == 1) and
-  // inserts (argv[0] == null).
-  if (argc < 2 || sqlite3_value_type(argv[0]) == SQLITE_NULL) {
-    return base::ErrStatus(
-        "Invalid number/value of arguments when updating window table");
+int WindowOperatorModule::Connect(sqlite3* db,
+                                  void* raw_ctx,
+                                  int argc,
+                                  const char* const* argv,
+                                  sqlite3_vtab** vtab,
+                                  char**) {
+  PERFETTO_CHECK(argc == 3);
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
   }
+  auto* ctx = GetContext(raw_ctx);
+  auto* ptr = ctx->state_by_name.Find(argv[2]);
+  PERFETTO_CHECK(ptr);
+  ptr->get()->disconnected = false;
 
-  int64_t new_quantum = sqlite3_value_int64(argv[3]);
-  int64_t new_start = sqlite3_value_int64(argv[4]);
-  int64_t new_dur = sqlite3_value_int64(argv[5]);
-  if (new_dur == 0) {
-    return base::ErrStatus("Cannot set duration of window table to zero.");
-  }
-
-  quantum_ = new_quantum;
-  window_start_ = new_start;
-  window_dur_ = new_dur;
-
-  return base::OkStatus();
+  std::unique_ptr<Vtab> res = std::make_unique<Vtab>();
+  res->context = ctx;
+  res->name = argv[2];
+  res->state = ptr->get();
+  *vtab = res.release();
+  return SQLITE_OK;
 }
 
-WindowOperatorTable::Cursor::Cursor(WindowOperatorTable* table)
-    : SqliteTable::BaseCursor(table), table_(table) {}
-WindowOperatorTable::Cursor::~Cursor() = default;
+int WindowOperatorModule::Disconnect(sqlite3_vtab* vtab) {
+  auto* tab = GetVtab(vtab);
+  auto* ptr = tab->context->state_by_name.Find(tab->name);
+  PERFETTO_CHECK(ptr);
+  ptr->get()->disconnected = true;
+  delete tab;
+  return SQLITE_OK;
+}
 
-base::Status WindowOperatorTable::Cursor::Filter(const QueryConstraints& qc,
-                                                 sqlite3_value** argv,
-                                                 FilterHistory) {
-  *this = Cursor(table_);
-  window_start_ = table_->window_start_;
-  window_end_ = table_->window_start_ + table_->window_dur_;
-  step_size_ = table_->quantum_ == 0 ? table_->window_dur_ : table_->quantum_;
-
-  current_ts_ = window_start_;
+int WindowOperatorModule::BestIndex(sqlite3_vtab*, sqlite3_index_info* info) {
+  info->orderByConsumed = info->nOrderBy == 1 &&
+                          info->aOrderBy[0].iColumn == Column::kTs &&
+                          !info->aOrderBy[0].desc;
 
   // Set return first if there is a equals constraint on the row id asking to
   // return the first row.
-  bool return_first = qc.constraints().size() == 1 &&
-                      qc.constraints()[0].column == Column::kRowId &&
-                      sqlite::utils::IsOpEq(qc.constraints()[0].op) &&
-                      sqlite3_value_int(argv[0]) == 0;
-  if (return_first) {
-    filter_type_ = FilterType::kReturnFirst;
+  bool is_row_id_constraint = info->nConstraint == 1 &&
+                              info->aConstraint[0].iColumn == Column::kRowId &&
+                              info->aConstraint[0].usable &&
+                              sqlite::utils::IsOpEq(info->aConstraint[0].op);
+  if (is_row_id_constraint) {
+    info->idxNum = 1;
+    info->aConstraintUsage[0].argvIndex = 1;
   } else {
-    filter_type_ = FilterType::kReturnAll;
+    info->idxNum = 0;
   }
-  return base::OkStatus();
+  return SQLITE_OK;
 }
 
-base::Status WindowOperatorTable::Cursor::Column(sqlite3_context* context,
-                                                 int N) {
+int WindowOperatorModule::Open(sqlite3_vtab*, sqlite3_vtab_cursor** cursor) {
+  std::unique_ptr<Cursor> c = std::make_unique<Cursor>();
+  *cursor = c.release();
+  return SQLITE_OK;
+}
+
+int WindowOperatorModule::Close(sqlite3_vtab_cursor* cursor) {
+  delete GetCursor(cursor);
+  return SQLITE_OK;
+}
+
+int WindowOperatorModule::Filter(sqlite3_vtab_cursor* cursor,
+                                 int is_row_id_constraint,
+                                 const char*,
+                                 int argc,
+                                 sqlite3_value** argv) {
+  auto* t = GetVtab(cursor->pVtab);
+  auto* c = GetCursor(cursor);
+
+  c->window_start = t->state->window_start;
+  c->window_end = t->state->window_start + t->state->window_dur;
+  c->step_size =
+      t->state->quantum == 0 ? t->state->window_dur : t->state->quantum;
+
+  c->current_ts = c->window_start;
+
+  if (is_row_id_constraint) {
+    PERFETTO_CHECK(argc == 1);
+    c->filter_type = sqlite3_value_int(argv[0]) == 0 ? FilterType::kReturnFirst
+                                                     : FilterType::kReturnAll;
+  } else {
+    c->filter_type = FilterType::kReturnAll;
+  }
+  return SQLITE_OK;
+}
+
+int WindowOperatorModule::Next(sqlite3_vtab_cursor* cursor) {
+  auto* c = GetCursor(cursor);
+  switch (c->filter_type) {
+    case FilterType::kReturnFirst:
+      c->current_ts = c->window_end;
+      break;
+    case FilterType::kReturnAll:
+      c->current_ts += c->step_size;
+      c->quantum_ts++;
+      break;
+  }
+  c->row_id++;
+  return SQLITE_OK;
+}
+
+int WindowOperatorModule::Eof(sqlite3_vtab_cursor* cursor) {
+  auto* c = GetCursor(cursor);
+  return c->current_ts >= c->window_end;
+}
+
+int WindowOperatorModule::Column(sqlite3_vtab_cursor* cursor,
+                                 sqlite3_context* ctx,
+                                 int N) {
+  auto* t = GetVtab(cursor->pVtab);
+  auto* c = GetCursor(cursor);
   switch (N) {
     case Column::kQuantum: {
-      sqlite::result::Long(context,
-                           static_cast<sqlite_int64>(table_->quantum_));
+      sqlite::result::Long(ctx, static_cast<sqlite_int64>(t->state->quantum));
       break;
     }
     case Column::kWindowStart: {
-      sqlite::result::Long(context,
-                           static_cast<sqlite_int64>(table_->window_start_));
+      sqlite::result::Long(ctx,
+                           static_cast<sqlite_int64>(t->state->window_start));
       break;
     }
     case Column::kWindowDur: {
-      sqlite::result::Long(context, static_cast<int>(table_->window_dur_));
+      sqlite::result::Long(ctx, static_cast<int>(t->state->window_dur));
       break;
     }
     case Column::kTs: {
-      sqlite::result::Long(context, static_cast<sqlite_int64>(current_ts_));
+      sqlite::result::Long(ctx, static_cast<sqlite_int64>(c->current_ts));
       break;
     }
     case Column::kDuration: {
-      sqlite::result::Long(context, static_cast<sqlite_int64>(step_size_));
+      sqlite::result::Long(ctx, static_cast<sqlite_int64>(c->step_size));
       break;
     }
     case Column::kQuantumTs: {
-      sqlite::result::Long(context, static_cast<sqlite_int64>(quantum_ts_));
+      sqlite::result::Long(ctx, static_cast<sqlite_int64>(c->quantum_ts));
       break;
     }
     case Column::kRowId: {
-      sqlite::result::Long(context, static_cast<sqlite_int64>(row_id_));
+      sqlite::result::Long(ctx, static_cast<sqlite_int64>(c->row_id));
       break;
     }
     default: {
@@ -159,26 +221,39 @@
       break;
     }
   }
-  return base::OkStatus();
+  return SQLITE_OK;
 }
 
-base::Status WindowOperatorTable::Cursor::Next() {
-  switch (filter_type_) {
-    case FilterType::kReturnFirst:
-      current_ts_ = window_end_;
-      break;
-    case FilterType::kReturnAll:
-      current_ts_ += step_size_;
-      quantum_ts_++;
-      break;
+int WindowOperatorModule::Rowid(sqlite3_vtab_cursor*, sqlite_int64*) {
+  return SQLITE_ERROR;
+}
+
+int WindowOperatorModule::Update(sqlite3_vtab* tab,
+                                 int argc,
+                                 sqlite3_value** argv,
+                                 sqlite_int64*) {
+  auto* t = GetVtab(tab);
+
+  // We only support updates to ts and dur. Disallow deletes (argc == 1) and
+  // inserts (argv[0] == null).
+  if (argc < 2 || sqlite3_value_type(argv[0]) == SQLITE_NULL) {
+    return sqlite::utils::SetError(
+        tab, "Invalid number/value of arguments when updating window table");
   }
-  row_id_++;
-  return base::OkStatus();
+
+  int64_t new_quantum = sqlite3_value_int64(argv[3]);
+  int64_t new_start = sqlite3_value_int64(argv[4]);
+  int64_t new_dur = sqlite3_value_int64(argv[5]);
+  if (new_dur == 0) {
+    return sqlite::utils::SetError(
+        tab, "Cannot set duration of window table to zero.");
+  }
+
+  t->state->quantum = new_quantum;
+  t->state->window_start = new_start;
+  t->state->window_dur = new_dur;
+
+  return SQLITE_OK;
 }
 
-bool WindowOperatorTable::Cursor::Eof() {
-  return current_ts_ >= window_end_;
-}
-
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.h b/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.h
index 8e26961..d023042 100644
--- a/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.h
+++ b/src/trace_processor/perfetto_sql/intrinsics/operators/window_operator.h
@@ -17,20 +17,55 @@
 #ifndef SRC_TRACE_PROCESSOR_PERFETTO_SQL_INTRINSICS_OPERATORS_WINDOW_OPERATOR_H_
 #define SRC_TRACE_PROCESSOR_PERFETTO_SQL_INTRINSICS_OPERATORS_WINDOW_OPERATOR_H_
 
+#include <cstdint>
 #include <limits>
 #include <memory>
+#include <string>
 
-#include "perfetto/base/status.h"
-#include "src/trace_processor/sqlite/sqlite_table.h"
+#include "perfetto/ext/base/flat_hash_map.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_module.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
 class TraceStorage;
 
-class WindowOperatorTable final
-    : public TypedSqliteTable<WindowOperatorTable, const TraceStorage*> {
- public:
+// Operator table which can emit spans of a configurable duration.
+struct WindowOperatorModule : sqlite::Module<WindowOperatorModule> {
+  // Defines the data to be generated by the table.
+  enum FilterType {
+    // Returns all the spans.
+    kReturnAll = 0,
+    // Only returns the first span of the table. Useful for UPDATE operations.
+    kReturnFirst = 1,
+  };
+  struct State {
+    bool disconnected = false;
+    int64_t quantum = 0;
+    int64_t window_start = 0;
+
+    // max of int64_t because SQLite technically only supports int64s and not
+    // uint64s.
+    int64_t window_dur = std::numeric_limits<int64_t>::max();
+  };
+  struct Context {
+    base::FlatHashMap<std::string, std::unique_ptr<State>> state_by_name;
+  };
+  struct Vtab : sqlite::Module<WindowOperatorModule>::Vtab {
+    Context* context;
+    std::string name;
+    State* state = nullptr;
+  };
+  struct Cursor : sqlite::Module<WindowOperatorModule>::Cursor {
+    int64_t window_start = 0;
+    int64_t window_end = 0;
+    int64_t step_size = 0;
+
+    int64_t current_ts = 0;
+    int64_t quantum_ts = 0;
+    int64_t row_id = 0;
+
+    FilterType filter_type = FilterType::kReturnAll;
+  };
   enum Column {
     kRowId = 0,
     kQuantum = 1,
@@ -40,64 +75,44 @@
     kDuration = 5,
     kQuantumTs = 6
   };
-  class Cursor final : public SqliteTable::BaseCursor {
-   public:
-    explicit Cursor(WindowOperatorTable*);
-    ~Cursor() final;
 
-    Cursor(Cursor&&) = default;
-    Cursor& operator=(Cursor&&) = default;
+  static constexpr auto kType = kCreateOnly;
+  static constexpr bool kDoesOverloadFunctions = false;
 
-    // Implementation of SqliteTable::Cursor.
-    base::Status Filter(const QueryConstraints& qc,
-                        sqlite3_value**,
-                        FilterHistory);
-    base::Status Next();
-    bool Eof();
-    base::Status Column(sqlite3_context*, int N);
+  static int Create(sqlite3*,
+                    void*,
+                    int,
+                    const char* const*,
+                    sqlite3_vtab**,
+                    char**);
+  static int Destroy(sqlite3_vtab*);
 
-   private:
-    // Defines the data to be generated by the table.
-    enum FilterType {
-      // Returns all the spans.
-      kReturnAll = 0,
-      // Only returns the first span of the table. Useful for UPDATE operations.
-      kReturnFirst = 1,
-    };
+  static int Connect(sqlite3*,
+                     void*,
+                     int,
+                     const char* const*,
+                     sqlite3_vtab**,
+                     char**);
+  static int Disconnect(sqlite3_vtab*);
 
-    int64_t window_start_ = 0;
-    int64_t window_end_ = 0;
-    int64_t step_size_ = 0;
+  static int BestIndex(sqlite3_vtab*, sqlite3_index_info*);
 
-    int64_t current_ts_ = 0;
-    int64_t quantum_ts_ = 0;
-    int64_t row_id_ = 0;
+  static int Open(sqlite3_vtab*, sqlite3_vtab_cursor**);
+  static int Close(sqlite3_vtab_cursor*);
 
-    FilterType filter_type_ = FilterType::kReturnAll;
+  static int Filter(sqlite3_vtab_cursor*,
+                    int,
+                    const char*,
+                    int,
+                    sqlite3_value**);
+  static int Next(sqlite3_vtab_cursor*);
+  static int Eof(sqlite3_vtab_cursor*);
+  static int Column(sqlite3_vtab_cursor*, sqlite3_context*, int);
+  static int Rowid(sqlite3_vtab_cursor*, sqlite_int64*);
 
-    WindowOperatorTable* table_ = nullptr;
-  };
-
-  WindowOperatorTable(sqlite3*, const TraceStorage*);
-  ~WindowOperatorTable() final;
-
-  // Table implementation.
-  base::Status Init(int, const char* const*, Schema* schema) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
-  int BestIndex(const QueryConstraints&, BestIndexInfo*) final;
-  base::Status ModifyConstraints(QueryConstraints* qc) final;
-  base::Status Update(int, sqlite3_value**, sqlite3_int64*) final;
-
- private:
-  int64_t quantum_ = 0;
-  int64_t window_start_ = 0;
-
-  // max of int64_t because SQLite technically only supports int64s and not
-  // uint64s.
-  int64_t window_dur_ = std::numeric_limits<int64_t>::max();
+  static int Update(sqlite3_vtab*, int, sqlite3_value**, sqlite_int64*);
 };
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
 
 #endif  // SRC_TRACE_PROCESSOR_PERFETTO_SQL_INTRINSICS_OPERATORS_WINDOW_OPERATOR_H_
diff --git a/src/trace_processor/sqlite/BUILD.gn b/src/trace_processor/sqlite/BUILD.gn
index e82712d..0a3beef 100644
--- a/src/trace_processor/sqlite/BUILD.gn
+++ b/src/trace_processor/sqlite/BUILD.gn
@@ -28,15 +28,12 @@
     "sql_stats_table.h",
     "sqlite_engine.cc",
     "sqlite_engine.h",
-    "sqlite_result.h",
     "sqlite_table.cc",
     "sqlite_table.h",
     "sqlite_tokenizer.cc",
     "sqlite_tokenizer.h",
     "sqlite_utils.cc",
     "sqlite_utils.h",
-    "sqlite_utils.h",
-    "sqlite_window_function.h",
     "stats_table.cc",
     "stats_table.h",
   ]
@@ -61,6 +58,7 @@
     "../util:profile_builder",
     "../util:regex",
   ]
+  public_deps = [ "bindings" ]
 }
 
 source_set("query_constraints") {
diff --git a/src/trace_processor/sqlite/bindings/BUILD.gn b/src/trace_processor/sqlite/bindings/BUILD.gn
new file mode 100644
index 0000000..b61cfde
--- /dev/null
+++ b/src/trace_processor/sqlite/bindings/BUILD.gn
@@ -0,0 +1,31 @@
+# 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("../../../../gn/test.gni")
+
+assert(enable_perfetto_trace_processor_sqlite)
+
+source_set("bindings") {
+  sources = [
+    "sqlite_aggregate_function.h",
+    "sqlite_module.h",
+    "sqlite_result.h",
+    "sqlite_window_function.h",
+  ]
+  deps = [
+    "../../../../gn:default_deps",
+    "../../../../gn:sqlite",
+  ]
+  visibility = [ "..:sqlite" ]
+}
diff --git a/src/trace_processor/sqlite/bindings/README.md b/src/trace_processor/sqlite/bindings/README.md
new file mode 100644
index 0000000..fff42e8
--- /dev/null
+++ b/src/trace_processor/sqlite/bindings/README.md
@@ -0,0 +1,3 @@
+This folder contains very lightweight bindings around SQLite to adapt
+it C++. Any non-trivial code should not live in this folder but in some
+higher layer.
\ No newline at end of file
diff --git a/src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h b/src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h
new file mode 100644
index 0000000..103dc32
--- /dev/null
+++ b/src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_AGGREGATE_FUNCTION_H_
+#define SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_AGGREGATE_FUNCTION_H_
+
+struct sqlite3_context;
+struct sqlite3_value;
+
+namespace perfetto::trace_processor {
+
+// Prototype for a aggregate function which can be registered with SQLite.
+//
+// See https://www.sqlite.org/c3ref/create_function.html for details on how to
+// implement the methods of this class.
+struct SqliteAggregateFunction {
+  // The type of the context object which will be passed to the function.
+  // Can be redefined in any sub-classes to override the context.
+  using Context = void;
+
+  // The xStep function which will be executed by SQLite to add a row of values
+  // to the aggregate.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static void Step(sqlite3_context*, int argc, sqlite3_value** argv);
+
+  // The xFinal function which will be executed by SQLite to obtain the current
+  // value of the aggregate *and* free all resources allocated by previous calls
+  // to Step.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static void Final(sqlite3_context* ctx);
+};
+
+}  // namespace perfetto::trace_processor
+
+#endif  // SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_AGGREGATE_FUNCTION_H_
diff --git a/src/trace_processor/sqlite/bindings/sqlite_module.h b/src/trace_processor/sqlite/bindings/sqlite_module.h
new file mode 100644
index 0000000..0da20cc
--- /dev/null
+++ b/src/trace_processor/sqlite/bindings/sqlite_module.h
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache, 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, software
+ * distributed under the License is distributed on an "AS IS",
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_MODULE_H_
+#define SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_MODULE_H_
+
+#include <sqlite3.h>
+
+namespace perfetto::trace_processor::sqlite {
+
+// Prototype for a virtual table (vtab) module which can be registered with
+// SQLite.
+//
+// See https://www.sqlite.org/vtab.html for how to implement this class.
+template <typename Impl>
+struct Module {
+  // Specifies the type of module: implementations can override this field by
+  // declaring and defining it.
+  //
+  // Specifying this to kCreateOnly requires that the |Create| and |Destroy|
+  // functions are defined.
+  //
+  // See the SQLite documentation on what these types mean.
+  static constexpr enum { kEponymousOnly, kCreateOnly } kType = kCreateOnly;
+
+  // Specifies whether this table is supports making changes to it:
+  // implementations can override this field by declaring and defining it.
+  //
+  // Setting this to true requires the |Update| function to be defined.
+  static constexpr bool kSupportsWrites = true;
+
+  // Specifies whether this table supports overloading functions:
+  // implementations can override this field by declaring and defining it.
+  //
+  // Setting this to true requires that the |FindFunction| function is defined.
+  static constexpr bool kDoesOverloadFunctions = true;
+
+  // sqlite3_module object corresponding to the module. Used to pass information
+  // about this module to SQLite.
+  //
+  // Note: this has to be defined here to allow referencing the functions
+  // defined above.
+  static constexpr sqlite3_module kModule = []() {
+    sqlite3_module module{};
+    module.xBestIndex = &Impl::BestIndex;
+    module.xOpen = &Impl::Open;
+    module.xClose = &Impl::Close;
+    module.xFilter = &Impl::Filter;
+    module.xNext = &Impl::Next;
+    module.xEof = &Impl::Eof;
+    module.xColumn = &Impl::Column;
+    module.xRowid = &Impl::Rowid;
+    if constexpr (Impl::kType == kCreateOnly) {
+      module.xCreate = &Impl::Create;
+      module.xDestroy = &Impl::Destroy;
+      module.xConnect = &Impl::Connect;
+      module.xDisconnect = &Impl::Disconnect;
+    } else {
+      module.xCreate = nullptr;
+      module.xDestroy = [](sqlite3_vtab*) -> int {
+        __builtin_trap();
+        __builtin_unreachable();
+      };
+      module.xConnect = &Impl::Connect;
+      module.xDisconnect = &Impl::Disconnect;
+    }
+    if constexpr (Impl::kSupportsWrites) {
+      module.xUpdate = &Impl::Update;
+    }
+    if constexpr (Impl::kDoesOverloadFunctions) {
+      module.xFindFunction = &Impl::FindFunction;
+    }
+    return module;
+  }();
+
+  // Specifies the type of context for the module. Implementations should define
+  // this type to match the context type which is expected to be passed into
+  // |sqlite3_create_module|.
+  using Context = void;
+
+  // Specifies the type for the vtab created by this module.
+  //
+  // Implementations should define this type to match the vtab type they use in
+  // |Create| and |Connect|.
+  using Vtab = sqlite3_vtab;
+
+  // Specifies the type for the cursor created by this module.
+  //
+  // Implementations should define this type to match the cursor type they use
+  // in |Open| and |Close|.
+  using Cursor = sqlite3_vtab_cursor;
+
+  // Creates a new instance of a virtual table and its backing storage.
+  //
+  // Implementations MUST define this function themselves if
+  // |kType| == |kCreateOnly|; this function is declared but *not* defined so
+  // linker errors will be thrown if not defined.
+  static int Create(sqlite3*,
+                    void*,
+                    int,
+                    const char* const*,
+                    sqlite3_vtab**,
+                    char**);
+
+  // Destroys the virtual table and its backing storage.
+  //
+  // Implementations MUST define this function themselves if
+  // |kType| == |kCreateOnly|; this function is declared but *not* defined so
+  // linker errors will be thrown if not defined.
+  static int Destroy(sqlite3_vtab*);
+
+  // Creates a new instance of the virtual table, connecting to existing
+  // backing storage.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Connect(sqlite3*,
+                     void*,
+                     int,
+                     const char* const*,
+                     sqlite3_vtab**,
+                     char**);
+
+  // Destroys the virtual table but *not* its backing storage.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Disconnect(sqlite3_vtab*);
+
+  // Specifies filtering and cost information for the query planner.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int BestIndex(sqlite3_vtab*, sqlite3_index_info*);
+
+  // Opens a cursor into the given vtab.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Open(sqlite3_vtab*, sqlite3_vtab_cursor**);
+
+  // Closes the cursor.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Close(sqlite3_vtab_cursor*);
+
+  // Resets this cursor to filter rows matching the provided set of filter
+  // constraints and order by clauses.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Filter(sqlite3_vtab_cursor*,
+                    int,
+                    const char*,
+                    int,
+                    sqlite3_value**);
+
+  // Forwards the cursor to point to the next row.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Next(sqlite3_vtab_cursor*);
+
+  // Returns 1 if the cursor has reached its end or 0 otherwise.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Eof(sqlite3_vtab_cursor*);
+
+  // Returns the value column at the given index for the current row the cursor
+  // points to.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Column(sqlite3_vtab_cursor*, sqlite3_context*, int);
+
+  // Returns the rowid for the current row.
+  //
+  // Implementations MUST define this function themselves; this function is
+  // declared but *not* defined so linker errors will be thrown if not defined.
+  static int Rowid(sqlite3_vtab_cursor*, sqlite_int64*);
+
+  // Inserts/deletes/updates one row.
+  //
+  // Implementations MUST define this function themselves if
+  // |kSupportsWrites| == |true|; this function is declared but *not* defined so
+  // linker errors will be thrown if not defined.
+  static int Update(sqlite3_vtab*, int, sqlite3_value**, sqlite_int64*);
+
+  // Overloads a function with the given name when executed with a vtab column
+  // as the first argument.
+  //
+  // Implementations MUST define this function themselves if
+  // |kDoesOverloadFunctions| == |true|; this function is declared but *not*
+  // defined so linker errors will be thrown if not defined.
+  static int FindFunction(sqlite3_vtab*,
+                          int,
+                          const char*,
+                          void (**)(sqlite3_context*, int, sqlite3_value**),
+                          void**);
+
+  // Helper function to cast the module context pointer to the correct type.
+  static auto GetContext(void* ctx) {
+    return static_cast<typename Impl::Context*>(ctx);
+  }
+
+  // Helper function to cast the vtab pointer to the correct type.
+  static auto GetVtab(sqlite3_vtab* vtab) {
+    return static_cast<typename Impl::Vtab*>(vtab);
+  }
+
+  // Helper function to cast the cursor pointer to the correct type.
+  static auto GetCursor(sqlite3_vtab_cursor* cursor) {
+    return static_cast<typename Impl::Cursor*>(cursor);
+  }
+};
+
+}  // namespace perfetto::trace_processor::sqlite
+
+#endif  // SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_MODULE_H_
diff --git a/src/trace_processor/sqlite/sqlite_result.h b/src/trace_processor/sqlite/bindings/sqlite_result.h
similarity index 93%
rename from src/trace_processor/sqlite/sqlite_result.h
rename to src/trace_processor/sqlite/bindings/sqlite_result.h
index 4030043..5c1fb21 100644
--- a/src/trace_processor/sqlite/sqlite_result.h
+++ b/src/trace_processor/sqlite/bindings/sqlite_result.h
@@ -14,8 +14,8 @@
  * limitations under the License.
  */
 
-#ifndef SRC_TRACE_PROCESSOR_SQLITE_SQLITE_RESULT_H_
-#define SRC_TRACE_PROCESSOR_SQLITE_SQLITE_RESULT_H_
+#ifndef SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_RESULT_H_
+#define SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_RESULT_H_
 
 #include <sqlite3.h>
 #include <cstdint>
@@ -92,4 +92,4 @@
 
 }  // namespace perfetto::trace_processor::sqlite::result
 
-#endif  // SRC_TRACE_PROCESSOR_SQLITE_SQLITE_RESULT_H_
+#endif  // SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_RESULT_H_
diff --git a/src/trace_processor/sqlite/sqlite_window_function.h b/src/trace_processor/sqlite/bindings/sqlite_window_function.h
similarity index 92%
rename from src/trace_processor/sqlite/sqlite_window_function.h
rename to src/trace_processor/sqlite/bindings/sqlite_window_function.h
index ac61c4f..cba290b 100644
--- a/src/trace_processor/sqlite/sqlite_window_function.h
+++ b/src/trace_processor/sqlite/bindings/sqlite_window_function.h
@@ -14,8 +14,8 @@
  * limitations under the License.
  */
 
-#ifndef SRC_TRACE_PROCESSOR_SQLITE_SQLITE_WINDOW_FUNCTION_H_
-#define SRC_TRACE_PROCESSOR_SQLITE_SQLITE_WINDOW_FUNCTION_H_
+#ifndef SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_WINDOW_FUNCTION_H_
+#define SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_WINDOW_FUNCTION_H_
 
 struct sqlite3_context;
 struct sqlite3_value;
@@ -64,4 +64,4 @@
 
 }  // namespace perfetto::trace_processor
 
-#endif  // SRC_TRACE_PROCESSOR_SQLITE_SQLITE_WINDOW_FUNCTION_H_
+#endif  // SRC_TRACE_PROCESSOR_SQLITE_BINDINGS_SQLITE_WINDOW_FUNCTION_H_
diff --git a/src/trace_processor/sqlite/db_sqlite_table.cc b/src/trace_processor/sqlite/db_sqlite_table.cc
index b0b6678..8c1d9a0 100644
--- a/src/trace_processor/sqlite/db_sqlite_table.cc
+++ b/src/trace_processor/sqlite/db_sqlite_table.cc
@@ -210,9 +210,10 @@
   return base::OkStatus();
 }
 
-SqliteTable::Schema DbSqliteTable::ComputeSchema(const Table::Schema& schema,
-                                                 const char* table_name) {
-  std::vector<SqliteTable::Column> schema_cols;
+SqliteTableLegacy::Schema DbSqliteTable::ComputeSchema(
+    const Table::Schema& schema,
+    const char* table_name) {
+  std::vector<SqliteTableLegacy::Column> schema_cols;
   for (uint32_t i = 0; i < schema.columns.size(); ++i) {
     const auto& col = schema.columns[i];
     schema_cols.emplace_back(i, col.name, col.type, col.is_hidden);
@@ -437,12 +438,12 @@
   return QueryCost{final_cost, current_row_count};
 }
 
-std::unique_ptr<SqliteTable::BaseCursor> DbSqliteTable::CreateCursor() {
+std::unique_ptr<SqliteTableLegacy::BaseCursor> DbSqliteTable::CreateCursor() {
   return std::make_unique<Cursor>(this, context_->cache);
 }
 
 DbSqliteTable::Cursor::Cursor(DbSqliteTable* sqlite_table, QueryCache* cache)
-    : SqliteTable::BaseCursor(sqlite_table),
+    : SqliteTableLegacy::BaseCursor(sqlite_table),
       db_sqlite_table_(sqlite_table),
       cache_(cache) {
   switch (db_sqlite_table_->context_->computation) {
diff --git a/src/trace_processor/sqlite/db_sqlite_table.h b/src/trace_processor/sqlite/db_sqlite_table.h
index 82993ca..ded0f84 100644
--- a/src/trace_processor/sqlite/db_sqlite_table.h
+++ b/src/trace_processor/sqlite/db_sqlite_table.h
@@ -89,7 +89,7 @@
   using Context = DbSqliteTableContext;
   using TableComputation = Context::Computation;
 
-  class Cursor final : public SqliteTable::BaseCursor {
+  class Cursor final : public SqliteTableLegacy::BaseCursor {
    public:
     Cursor(DbSqliteTable*, QueryCache*);
     ~Cursor() final;
@@ -100,7 +100,7 @@
     Cursor(Cursor&&) noexcept = delete;
     Cursor& operator=(Cursor&&) = delete;
 
-    // Implementation of SqliteTable::Cursor.
+    // Implementation of SqliteTableLegacy::Cursor.
     base::Status Filter(const QueryConstraints& qc,
                         sqlite3_value** argv,
                         FilterHistory);
@@ -197,15 +197,15 @@
   virtual ~DbSqliteTable() final;
 
   // Table implementation.
-  base::Status Init(int, const char* const*, SqliteTable::Schema*) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
+  base::Status Init(int, const char* const*, SqliteTableLegacy::Schema*) final;
+  std::unique_ptr<SqliteTableLegacy::BaseCursor> CreateCursor() final;
   base::Status ModifyConstraints(QueryConstraints*) final;
   int BestIndex(const QueryConstraints&, BestIndexInfo*) final;
 
   // These static functions are useful to allow other callers to make use
   // of them.
-  static SqliteTable::Schema ComputeSchema(const Table::Schema&,
-                                           const char* table_name);
+  static SqliteTableLegacy::Schema ComputeSchema(const Table::Schema&,
+                                                 const char* table_name);
   static void ModifyConstraints(const Table::Schema&, QueryConstraints*);
   static void BestIndex(const Table::Schema&,
                         uint32_t row_count,
diff --git a/src/trace_processor/sqlite/query_constraints.h b/src/trace_processor/sqlite/query_constraints.h
index 6ff7e5b..8fc08e7 100644
--- a/src/trace_processor/sqlite/query_constraints.h
+++ b/src/trace_processor/sqlite/query_constraints.h
@@ -46,8 +46,8 @@
     int op;
 
     // The original index of this constraint in the aConstraint array.
-    // Used internally by SqliteTable for xBestIndex - this should not be
-    // read or modified by subclasses of SqliteTable.
+    // Used internally by SqliteTableLegacy for xBestIndex - this should not be
+    // read or modified by subclasses of SqliteTableLegacy.
     int a_constraint_idx;
   };
   struct OrderBy {
diff --git a/src/trace_processor/sqlite/sql_source.cc b/src/trace_processor/sqlite/sql_source.cc
index ee86e8c..9de3a03 100644
--- a/src/trace_processor/sqlite/sql_source.cc
+++ b/src/trace_processor/sqlite/sql_source.cc
@@ -18,6 +18,7 @@
 
 #include <sqlite3.h>
 #include <algorithm>
+#include <cstddef>
 #include <cstdint>
 #include <iterator>
 #include <limits>
@@ -28,12 +29,16 @@
 #include <vector>
 
 #include "perfetto/base/logging.h"
-#include "perfetto/ext/base/flat_hash_map.h"
 #include "perfetto/ext/base/string_utils.h"
-#include "perfetto/ext/base/sys_types.h"
 
-namespace perfetto {
-namespace trace_processor {
+#if SQLITE_VERSION_NUMBER < 3041002
+// There is a bug in pre-3.41.2 versions of SQLite where sqlite3_error_offset
+// can return an offset out of bounds. Make it a hard compiler error to prevent
+// us from hitting this bug.
+#error "SQLite version is too old."
+#endif
+
+namespace perfetto::trace_processor {
 
 namespace {
 
@@ -47,7 +52,7 @@
 
   const char* new_start = sql.c_str() + offset;
   size_t prev_nl = sql.rfind('\n', offset - 1);
-  ssize_t nl_count = std::count(sql.c_str(), new_start, '\n');
+  int64_t nl_count = std::count(sql.c_str(), new_start, '\n');
   PERFETTO_DCHECK((nl_count == 0) == (prev_nl == std::string_view::npos));
 
   if (prev_nl == std::string::npos) {
@@ -55,7 +60,7 @@
                           column + static_cast<uint32_t>(offset));
   }
 
-  ssize_t new_column = std::distance(sql.c_str() + prev_nl, new_start);
+  int64_t new_column = std::distance(sql.c_str() + prev_nl, new_start);
   return std::make_pair(line + static_cast<uint32_t>(nl_count),
                         static_cast<uint32_t>(new_column));
 }
@@ -100,24 +105,24 @@
 }
 
 SqlSource SqlSource::FromExecuteQuery(std::string sql) {
-  return SqlSource(std::move(sql), "File \"stdin\"", true);
+  return {std::move(sql), "File \"stdin\"", true};
 }
 
 SqlSource SqlSource::FromMetric(std::string sql, const std::string& name) {
-  return SqlSource(std::move(sql), "Metric \"" + name + "\"", true);
+  return {std::move(sql), "Metric \"" + name + "\"", true};
 }
 
 SqlSource SqlSource::FromMetricFile(std::string sql, const std::string& name) {
-  return SqlSource(std::move(sql), "Metric file \"" + name + "\"", false);
+  return {std::move(sql), "Metric file \"" + name + "\"", false};
 }
 
 SqlSource SqlSource::FromModuleInclude(std::string sql,
                                        const std::string& module) {
-  return SqlSource(std::move(sql), "Module include \"" + module + "\"", false);
+  return {std::move(sql), "Module include \"" + module + "\"", false};
 }
 
 SqlSource SqlSource::FromTraceProcessorImplementation(std::string sql) {
-  return SqlSource(std::move(sql), "Trace Processor Internal", false);
+  return {std::move(sql), "Trace Processor Internal", false};
 }
 
 std::string SqlSource::AsTraceback(uint32_t offset) const {
@@ -127,16 +132,7 @@
 std::string SqlSource::AsTracebackForSqliteOffset(
     std::optional<uint32_t> opt_offset) const {
   uint32_t offset = opt_offset.value_or(0);
-  // Unfortunately, there is a bug in pre-3.41.2 versions of SQLite where
-  // sqlite3_error_offset can return an offset out of bounds. In these
-  // situations, zero the offset.
-#if SQLITE_VERSION_NUMBER < 3041002
-  if (offset >= sql().size()) {
-    offset = 0;
-  }
-#else
   PERFETTO_CHECK(offset <= sql().size());
-#endif
   return AsTraceback(offset);
 }
 
@@ -389,5 +385,4 @@
   return SqlSource(std::move(orig_));
 }
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/sqlite/sql_stats_table.cc b/src/trace_processor/sqlite/sql_stats_table.cc
index fb1d59c..3a7b1ab 100644
--- a/src/trace_processor/sqlite/sql_stats_table.cc
+++ b/src/trace_processor/sqlite/sql_stats_table.cc
@@ -17,86 +17,107 @@
 #include "src/trace_processor/sqlite/sql_stats_table.h"
 
 #include <sqlite3.h>
+#include <memory>
 
-#include <algorithm>
-#include <bitset>
-#include <numeric>
-
-#include "perfetto/base/status.h"
-#include "src/trace_processor/sqlite/sqlite_utils.h"
+#include "perfetto/base/logging.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/storage/trace_storage.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
-SqlStatsTable::SqlStatsTable(sqlite3*, const TraceStorage* storage)
-    : storage_(storage) {}
-SqlStatsTable::~SqlStatsTable() = default;
-
-base::Status SqlStatsTable::Init(int, const char* const*, Schema* schema) {
-  *schema = Schema(
-      {
-          SqliteTable::Column(Column::kQuery, "query", SqlValue::Type::kString),
-          SqliteTable::Column(Column::kTimeStarted, "started",
-                              SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kTimeFirstNext, "first_next",
-                              SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kTimeEnded, "ended",
-                              SqlValue::Type::kLong),
-      },
-      {Column::kTimeStarted});
-  return util::OkStatus();
-}
-
-std::unique_ptr<SqliteTable::BaseCursor> SqlStatsTable::CreateCursor() {
-  return std::unique_ptr<SqliteTable::BaseCursor>(new Cursor(this));
-}
-
-int SqlStatsTable::BestIndex(const QueryConstraints&, BestIndexInfo*) {
+int SqlStatsModule::Connect(sqlite3* db,
+                            void* aux,
+                            int,
+                            const char* const*,
+                            sqlite3_vtab** vtab,
+                            char**) {
+  static constexpr char kSchema[] = R"(
+    CREATE TABLE x(
+      query TEXT,
+      started BIGINT,
+      first_next BIGINT,
+      ended BIGINT,
+      PRIMARY KEY(started)
+    ) WITHOUT ROWID
+  )";
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
+  }
+  std::unique_ptr<Vtab> res = std::make_unique<Vtab>();
+  res->storage = GetContext(aux);
+  *vtab = res.release();
   return SQLITE_OK;
 }
 
-SqlStatsTable::Cursor::Cursor(SqlStatsTable* table)
-    : SqliteTable::BaseCursor(table),
-      storage_(table->storage_),
-      table_(table) {}
-SqlStatsTable::Cursor::~Cursor() = default;
-
-base::Status SqlStatsTable::Cursor::Filter(const QueryConstraints&,
-                                           sqlite3_value**,
-                                           FilterHistory) {
-  *this = Cursor(table_);
-  num_rows_ = storage_->sql_stats().size();
-  return base::OkStatus();
+int SqlStatsModule::Disconnect(sqlite3_vtab* vtab) {
+  delete GetVtab(vtab);
+  return SQLITE_OK;
 }
 
-base::Status SqlStatsTable::Cursor::Next() {
-  row_++;
-  return base::OkStatus();
+int SqlStatsModule::BestIndex(sqlite3_vtab*, sqlite3_index_info*) {
+  return SQLITE_OK;
 }
 
-bool SqlStatsTable::Cursor::Eof() {
-  return row_ >= num_rows_;
+int SqlStatsModule::Open(sqlite3_vtab* raw_vtab, sqlite3_vtab_cursor** cursor) {
+  std::unique_ptr<Cursor> c = std::make_unique<Cursor>();
+  c->storage = GetVtab(raw_vtab)->storage;
+  *cursor = c.release();
+  return SQLITE_OK;
 }
 
-base::Status SqlStatsTable::Cursor::Column(sqlite3_context* context, int col) {
-  const TraceStorage::SqlStats& stats = storage_->sql_stats();
-  switch (col) {
+int SqlStatsModule::Close(sqlite3_vtab_cursor* cursor) {
+  delete GetCursor(cursor);
+  return SQLITE_OK;
+}
+
+int SqlStatsModule::Filter(sqlite3_vtab_cursor* cursor,
+                           int,
+                           const char*,
+                           int,
+                           sqlite3_value**) {
+  auto* c = GetCursor(cursor);
+  c->row = 0;
+  c->num_rows = c->storage->sql_stats().size();
+  return SQLITE_OK;
+}
+
+int SqlStatsModule::Next(sqlite3_vtab_cursor* cursor) {
+  GetCursor(cursor)->row++;
+  return SQLITE_OK;
+}
+
+int SqlStatsModule::Eof(sqlite3_vtab_cursor* cursor) {
+  auto* c = GetCursor(cursor);
+  return c->row >= c->num_rows;
+}
+
+int SqlStatsModule::Column(sqlite3_vtab_cursor* cursor,
+                           sqlite3_context* ctx,
+                           int N) {
+  auto* c = GetCursor(cursor);
+  const TraceStorage::SqlStats& stats = c->storage->sql_stats();
+  switch (N) {
     case Column::kQuery:
-      sqlite::result::StaticString(context, stats.queries()[row_].c_str());
+      sqlite::result::StaticString(ctx, stats.queries()[c->row].c_str());
       break;
     case Column::kTimeStarted:
-      sqlite::result::Long(context, stats.times_started()[row_]);
+      sqlite::result::Long(ctx, stats.times_started()[c->row]);
       break;
     case Column::kTimeFirstNext:
-      sqlite::result::Long(context, stats.times_first_next()[row_]);
+      sqlite::result::Long(ctx, stats.times_first_next()[c->row]);
       break;
     case Column::kTimeEnded:
-      sqlite::result::Long(context, stats.times_ended()[row_]);
+      sqlite::result::Long(ctx, stats.times_ended()[c->row]);
+      break;
+    default:
+      PERFETTO_FATAL("Unknown column %d", N);
       break;
   }
-  return base::OkStatus();
+  return SQLITE_OK;
 }
 
-}  // namespace trace_processor
-}  // namespace perfetto
+int SqlStatsModule::Rowid(sqlite3_vtab_cursor*, sqlite_int64*) {
+  return SQLITE_ERROR;
+}
+
+}  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/sqlite/sql_stats_table.h b/src/trace_processor/sqlite/sql_stats_table.h
index d78224c..45d5b14 100644
--- a/src/trace_processor/sqlite/sql_stats_table.h
+++ b/src/trace_processor/sqlite/sql_stats_table.h
@@ -17,23 +17,27 @@
 #ifndef SRC_TRACE_PROCESSOR_SQLITE_SQL_STATS_TABLE_H_
 #define SRC_TRACE_PROCESSOR_SQLITE_SQL_STATS_TABLE_H_
 
-#include <limits>
-#include <memory>
+#include <cstddef>
 
-#include "perfetto/base/status.h"
-#include "src/trace_processor/sqlite/sqlite_table.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_module.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
 class QueryConstraints;
 class TraceStorage;
 
 // A virtual table that allows to introspect performances of the SQL engine
 // for the kMaxLogEntries queries.
-class SqlStatsTable final
-    : public TypedSqliteTable<SqlStatsTable, const TraceStorage*> {
- public:
+struct SqlStatsModule : sqlite::Module<SqlStatsModule> {
+  using Context = TraceStorage;
+  struct Vtab : sqlite::Module<SqlStatsModule>::Vtab {
+    TraceStorage* storage = nullptr;
+  };
+  struct Cursor : sqlite::Module<SqlStatsModule>::Cursor {
+    const TraceStorage* storage = nullptr;
+    size_t row = 0;
+    size_t num_rows = 0;
+  };
   enum Column {
     kQuery = 0,
     kTimeStarted = 1,
@@ -41,46 +45,34 @@
     kTimeEnded = 3,
   };
 
-  // Implementation of the SQLite cursor interface.
-  class Cursor final : public SqliteTable::BaseCursor {
-   public:
-    explicit Cursor(SqlStatsTable* storage);
-    ~Cursor() final;
+  static constexpr auto kType = kEponymousOnly;
+  static constexpr bool kSupportsWrites = false;
+  static constexpr bool kDoesOverloadFunctions = false;
 
-    // Implementation of SqliteTable::Cursor.
-    base::Status Filter(const QueryConstraints&,
-                        sqlite3_value**,
-                        FilterHistory);
-    base::Status Next();
-    bool Eof();
-    base::Status Column(sqlite3_context*, int N);
+  static int Connect(sqlite3*,
+                     void*,
+                     int,
+                     const char* const*,
+                     sqlite3_vtab**,
+                     char**);
+  static int Disconnect(sqlite3_vtab*);
 
-   private:
-    Cursor(Cursor&) = delete;
-    Cursor& operator=(const Cursor&) = delete;
+  static int BestIndex(sqlite3_vtab*, sqlite3_index_info*);
 
-    Cursor(Cursor&&) noexcept = default;
-    Cursor& operator=(Cursor&&) = default;
+  static int Open(sqlite3_vtab*, sqlite3_vtab_cursor**);
+  static int Close(sqlite3_vtab_cursor*);
 
-    size_t row_ = 0;
-    size_t num_rows_ = 0;
-    const TraceStorage* storage_ = nullptr;
-    SqlStatsTable* table_ = nullptr;
-  };
-
-  SqlStatsTable(sqlite3*, const TraceStorage* storage);
-  ~SqlStatsTable() final;
-
-  // Table implementation.
-  base::Status Init(int, const char* const*, Schema*) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
-  int BestIndex(const QueryConstraints&, BestIndexInfo*) final;
-
- private:
-  const TraceStorage* const storage_;
+  static int Filter(sqlite3_vtab_cursor*,
+                    int,
+                    const char*,
+                    int,
+                    sqlite3_value**);
+  static int Next(sqlite3_vtab_cursor*);
+  static int Eof(sqlite3_vtab_cursor*);
+  static int Column(sqlite3_vtab_cursor*, sqlite3_context*, int);
+  static int Rowid(sqlite3_vtab_cursor*, sqlite_int64*);
 };
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
 
 #endif  // SRC_TRACE_PROCESSOR_SQLITE_SQL_STATS_TABLE_H_
diff --git a/src/trace_processor/sqlite/sqlite_engine.cc b/src/trace_processor/sqlite/sqlite_engine.cc
index ec9c01d..00defc2 100644
--- a/src/trace_processor/sqlite/sqlite_engine.cc
+++ b/src/trace_processor/sqlite/sqlite_engine.cc
@@ -123,7 +123,7 @@
   for (auto it = all_created_sqlite_tables_.rbegin();
        it != all_created_sqlite_tables_.rend(); it++) {
     if (auto* type = sqlite_tables_.Find(*it);
-        !type || *type != SqliteTable::TableType::kExplicitCreate) {
+        !type || *type != SqliteTableLegacy::TableType::kExplicitCreate) {
       continue;
     }
     if (auto it_and_ins = dropped_tables.insert(*it); !it_and_ins.second) {
@@ -199,6 +199,24 @@
   return base::OkStatus();
 }
 
+base::Status SqliteEngine::RegisterAggregateFunction(
+    const char* name,
+    int argc,
+    AggregateFnStep* step,
+    AggregateFnFinal* final,
+    void* ctx,
+    FnCtxDestructor* destructor,
+    bool deterministic) {
+  int flags = SQLITE_UTF8 | (deterministic ? SQLITE_DETERMINISTIC : 0);
+  int ret =
+      sqlite3_create_function_v2(db_.get(), name, static_cast<int>(argc), flags,
+                                 ctx, nullptr, step, final, destructor);
+  if (ret != SQLITE_OK) {
+    return base::ErrStatus("Unable to register function with name %s", name);
+  }
+  return base::OkStatus();
+}
+
 base::Status SqliteEngine::RegisterWindowFunction(const char* name,
                                                   int argc,
                                                   WindowFnStep* step,
@@ -238,8 +256,9 @@
   return base::OkStatus();
 }
 
-base::Status SqliteEngine::SaveSqliteTable(const std::string& table_name,
-                                           std::unique_ptr<SqliteTable> table) {
+base::Status SqliteEngine::SaveSqliteTable(
+    const std::string& table_name,
+    std::unique_ptr<SqliteTableLegacy> table) {
   auto res = saved_tables_.Insert(table_name, {});
   if (!res.second) {
     return base::ErrStatus("Table with name %s already is saved",
@@ -249,14 +268,14 @@
   return base::OkStatus();
 }
 
-base::StatusOr<std::unique_ptr<SqliteTable>> SqliteEngine::RestoreSqliteTable(
-    const std::string& table_name) {
+base::StatusOr<std::unique_ptr<SqliteTableLegacy>>
+SqliteEngine::RestoreSqliteTable(const std::string& table_name) {
   auto* res = saved_tables_.Find(table_name);
   if (!res) {
     return base::ErrStatus("Table with name %s does not exist in saved state",
                            table_name.c_str());
   }
-  std::unique_ptr<SqliteTable> table = std::move(*res);
+  std::unique_ptr<SqliteTableLegacy> table = std::move(*res);
   PERFETTO_CHECK(saved_tables_.Erase(table_name));
   return std::move(table);
 }
@@ -271,7 +290,7 @@
 }
 
 void SqliteEngine::OnSqliteTableCreated(const std::string& name,
-                                        SqliteTable::TableType type) {
+                                        SqliteTableLegacy::TableType type) {
   auto it_and_inserted = sqlite_tables_.Insert(name, type);
   PERFETTO_CHECK(it_and_inserted.second);
   all_created_sqlite_tables_.push_back(name);
diff --git a/src/trace_processor/sqlite/sqlite_engine.h b/src/trace_processor/sqlite/sqlite_engine.h
index e46b9ee..55e991f 100644
--- a/src/trace_processor/sqlite/sqlite_engine.h
+++ b/src/trace_processor/sqlite/sqlite_engine.h
@@ -26,10 +26,12 @@
 #include <type_traits>
 #include <vector>
 
+#include "perfetto/base/logging.h"
 #include "perfetto/base/status.h"
 #include "perfetto/ext/base/flat_hash_map.h"
 #include "perfetto/ext/base/hash.h"
 #include "src/trace_processor/db/table.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_module.h"
 #include "src/trace_processor/sqlite/query_cache.h"
 #include "src/trace_processor/sqlite/scoped_db.h"
 #include "src/trace_processor/sqlite/sql_source.h"
@@ -51,6 +53,10 @@
 class SqliteEngine {
  public:
   using Fn = void(sqlite3_context* ctx, int argc, sqlite3_value** argv);
+  using AggregateFnStep = void(sqlite3_context* ctx,
+                               int argc,
+                               sqlite3_value** argv);
+  using AggregateFnFinal = void(sqlite3_context* ctx);
   using WindowFnStep = void(sqlite3_context* ctx,
                             int argc,
                             sqlite3_value** argv);
@@ -98,6 +104,15 @@
                                 FnCtxDestructor* ctx_destructor,
                                 bool deterministic);
 
+  // Registers a C++ aggregate function to be runnable from SQL.
+  base::Status RegisterAggregateFunction(const char* name,
+                                         int argc,
+                                         AggregateFnStep* step,
+                                         AggregateFnFinal* final,
+                                         void* ctx,
+                                         FnCtxDestructor* ctx_destructor,
+                                         bool deterministic);
+
   // Registers a C++ window function to be runnable from SQL.
   base::Status RegisterWindowFunction(const char* name,
                                       int argc,
@@ -113,10 +128,20 @@
   base::Status UnregisterFunction(const char* name, int argc);
 
   // Registers a SQLite virtual table module with the given name.
+  template <typename Module>
+  void RegisterVirtualTableModule(const std::string& module_name,
+                                  typename Module::Context* ctx);
+
+  // Registers a SQLite virtual table module with the given name.
+  template <typename Module>
+  void RegisterVirtualTableModule(const std::string& module_name,
+                                  std::unique_ptr<typename Module::Context>);
+
+  // Registers a SQLite virtual table module with the given name.
   template <typename Vtab, typename Context>
   void RegisterVirtualTableModule(const std::string& module_name,
                                   Context ctx,
-                                  SqliteTable::TableType table_type,
+                                  SqliteTableLegacy::TableType table_type,
                                   bool updatable);
 
   // Declares a virtual table with SQLite.
@@ -124,19 +149,20 @@
 
   // Saves a SQLite table across a pair of xDisconnect/xConnect callbacks.
   base::Status SaveSqliteTable(const std::string& table_name,
-                               std::unique_ptr<SqliteTable>);
+                               std::unique_ptr<SqliteTableLegacy>);
 
   // Restores a SQLite table across a pair of xDisconnect/xConnect callbacks.
-  base::StatusOr<std::unique_ptr<SqliteTable>> RestoreSqliteTable(
+  base::StatusOr<std::unique_ptr<SqliteTableLegacy>> RestoreSqliteTable(
       const std::string& table_name);
 
   // Gets the context for a registered SQL function.
   void* GetFunctionContext(const std::string& name, int argc);
 
-  // Should be called when a SqliteTable instance is created.
-  void OnSqliteTableCreated(const std::string& name, SqliteTable::TableType);
+  // Should be called when a SqliteTableLegacy instance is created.
+  void OnSqliteTableCreated(const std::string& name,
+                            SqliteTableLegacy::TableType);
 
-  // Should be called when a SqliteTable instance is destroyed.
+  // Should be called when a SqliteTableLegacy instance is destroyed.
   void OnSqliteTableDestroyed(const std::string& name);
 
   sqlite3* db() const { return db_.get(); }
@@ -156,9 +182,10 @@
   SqliteEngine(SqliteEngine&&) noexcept = delete;
   SqliteEngine& operator=(SqliteEngine&&) = delete;
 
-  base::FlatHashMap<std::string, SqliteTable::TableType> sqlite_tables_;
+  base::FlatHashMap<std::string, SqliteTableLegacy::TableType> sqlite_tables_;
   std::vector<std::string> all_created_sqlite_tables_;
-  base::FlatHashMap<std::string, std::unique_ptr<SqliteTable>> saved_tables_;
+  base::FlatHashMap<std::string, std::unique_ptr<SqliteTableLegacy>>
+      saved_tables_;
   base::FlatHashMap<std::pair<std::string, int>, void*, FnHasher> fn_ctx_;
 
   ScopedDb db_;
@@ -171,15 +198,37 @@
 // in the header file because it is templated code. We separate it out
 // like this to keep the API people actually care about easy to read.
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
+
+template <typename Module>
+void SqliteEngine::RegisterVirtualTableModule(const std::string& module_name,
+                                              typename Module::Context* ctx) {
+  static_assert(std::is_base_of_v<sqlite::Module<Module>, Module>,
+                "Must subclass sqlite::Module");
+  int res = sqlite3_create_module_v2(db_.get(), module_name.c_str(),
+                                     &Module::kModule, ctx, nullptr);
+  PERFETTO_CHECK(res == SQLITE_OK);
+}
+
+template <typename Module>
+void SqliteEngine::RegisterVirtualTableModule(
+    const std::string& module_name,
+    std::unique_ptr<typename Module::Context> ctx) {
+  static_assert(std::is_base_of_v<sqlite::Module<Module>, Module>,
+                "Must subclass sqlite::Module");
+  int res = sqlite3_create_module_v2(
+      db_.get(), module_name.c_str(), &Module::kModule, ctx.release(),
+      [](void* arg) { delete static_cast<typename Module::Context*>(arg); });
+  PERFETTO_CHECK(res == SQLITE_OK);
+}
 
 template <typename Vtab, typename Context>
-void SqliteEngine::RegisterVirtualTableModule(const std::string& module_name,
-                                              Context ctx,
-                                              SqliteTable::TableType table_type,
-                                              bool updatable) {
-  static_assert(std::is_base_of_v<SqliteTable, Vtab>,
+void SqliteEngine::RegisterVirtualTableModule(
+    const std::string& module_name,
+    Context ctx,
+    SqliteTableLegacy::TableType table_type,
+    bool updatable) {
+  static_assert(std::is_base_of_v<SqliteTableLegacy, Vtab>,
                 "Must subclass TypedSqliteTable");
 
   auto module_arg =
@@ -191,7 +240,6 @@
   PERFETTO_CHECK(res == SQLITE_OK);
 }
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
 
 #endif  // SRC_TRACE_PROCESSOR_SQLITE_SQLITE_ENGINE_H_
diff --git a/src/trace_processor/sqlite/sqlite_table.cc b/src/trace_processor/sqlite/sqlite_table.cc
index c599b2f..7411657 100644
--- a/src/trace_processor/sqlite/sqlite_table.cc
+++ b/src/trace_processor/sqlite/sqlite_table.cc
@@ -82,7 +82,7 @@
       return "limit";
     case SQLITE_INDEX_CONSTRAINT_OFFSET:
       return "offset";
-    case SqliteTable::CustomFilterOpcode::kSourceGeqOpCode:
+    case SqliteTableLegacy::CustomFilterOpcode::kSourceGeqOpCode:
       return "source_geq";
     default:
       PERFETTO_FATAL("Operator to string conversion not impemented for %d", op);
@@ -90,7 +90,7 @@
 }
 
 void ConstraintsToString(const QueryConstraints& qc,
-                         const SqliteTable::Schema& schema,
+                         const SqliteTableLegacy::Schema& schema,
                          std::string& out) {
   bool is_first = true;
   for (const auto& cs : qc.constraints()) {
@@ -105,7 +105,7 @@
 }
 
 void OrderByToString(const QueryConstraints& qc,
-                     const SqliteTable::Schema& schema,
+                     const SqliteTableLegacy::Schema& schema,
                      std::string& out) {
   bool is_first = true;
   for (const auto& ob : qc.order_by()) {
@@ -120,7 +120,7 @@
 }
 
 std::string QcDebugStr(const QueryConstraints& qc,
-                       const SqliteTable::Schema& schema) {
+                       const SqliteTableLegacy::Schema& schema) {
   std::string str_result;
   str_result.reserve(512);
 
@@ -144,7 +144,7 @@
 
 void WriteQueryConstraintsToMetatrace(metatrace::Record* r,
                                       const QueryConstraints& qc,
-                                      const SqliteTable::Schema& schema) {
+                                      const SqliteTableLegacy::Schema& schema) {
   r->AddArg("constraint_count", std::to_string(qc.constraints().size()));
   std::string constraints;
   ConstraintsToString(qc, schema, constraints);
@@ -159,24 +159,26 @@
 }  // namespace
 
 // static
-bool SqliteTable::debug = false;
+bool SqliteTableLegacy::debug = false;
 
-SqliteTable::SqliteTable() = default;
-SqliteTable::~SqliteTable() = default;
+SqliteTableLegacy::SqliteTableLegacy() = default;
+SqliteTableLegacy::~SqliteTableLegacy() = default;
 
-base::Status SqliteTable::ModifyConstraints(QueryConstraints*) {
+base::Status SqliteTableLegacy::ModifyConstraints(QueryConstraints*) {
   return base::OkStatus();
 }
 
-int SqliteTable::FindFunction(const char*, FindFunctionFn*, void**) {
+int SqliteTableLegacy::FindFunction(const char*, FindFunctionFn*, void**) {
   return 0;
 }
 
-base::Status SqliteTable::Update(int, sqlite3_value**, sqlite3_int64*) {
+base::Status SqliteTableLegacy::Update(int, sqlite3_value**, sqlite3_int64*) {
   return base::ErrStatus("Updating not supported");
 }
 
-bool SqliteTable::ReadConstraints(int idxNum, const char* idxStr, int argc) {
+bool SqliteTableLegacy::ReadConstraints(int idxNum,
+                                        const char* idxStr,
+                                        int argc) {
   bool cache_hit = true;
   if (idxNum != qc_hash_) {
     qc_cache_ = QueryConstraints::FromString(idxStr);
@@ -196,7 +198,7 @@
   // Logging this every ReadConstraints just leads to log spam on joins making
   // it unusable. Instead, only print this out when we miss the cache (which
   // happens precisely when the constraint set from SQLite changes.)
-  if (SqliteTable::debug && !cache_hit) {
+  if (SqliteTableLegacy::debug && !cache_hit) {
     PERFETTO_LOG("[%s::ParseConstraints] constraints=%s argc=%d", name_.c_str(),
                  QcDebugStr(qc_cache_, schema_).c_str(), argc);
   }
@@ -204,34 +206,35 @@
 }
 
 ////////////////////////////////////////////////////////////////////////////////
-// SqliteTable::BaseCursor implementation
+// SqliteTableLegacy::BaseCursor implementation
 ////////////////////////////////////////////////////////////////////////////////
 
-SqliteTable::BaseCursor::BaseCursor(SqliteTable* table) : table_(table) {
+SqliteTableLegacy::BaseCursor::BaseCursor(SqliteTableLegacy* table)
+    : table_(table) {
   // This is required to prevent us from leaving this field uninitialised if
   // we ever move construct the Cursor.
   pVtab = table;
 }
-SqliteTable::BaseCursor::~BaseCursor() = default;
+SqliteTableLegacy::BaseCursor::~BaseCursor() = default;
 
 ////////////////////////////////////////////////////////////////////////////////
-// SqliteTable::Column implementation
+// SqliteTableLegacy::Column implementation
 ////////////////////////////////////////////////////////////////////////////////
 
-SqliteTable::Column::Column(size_t index,
-                            std::string name,
-                            SqlValue::Type type,
-                            bool hidden)
+SqliteTableLegacy::Column::Column(size_t index,
+                                  std::string name,
+                                  SqlValue::Type type,
+                                  bool hidden)
     : index_(index), name_(name), type_(type), hidden_(hidden) {}
 
 ////////////////////////////////////////////////////////////////////////////////
-// SqliteTable::Schema implementation
+// SqliteTableLegacy::Schema implementation
 ////////////////////////////////////////////////////////////////////////////////
 
-SqliteTable::Schema::Schema() = default;
+SqliteTableLegacy::Schema::Schema() = default;
 
-SqliteTable::Schema::Schema(std::vector<Column> columns,
-                            std::vector<size_t> primary_keys)
+SqliteTableLegacy::Schema::Schema(std::vector<Column> columns,
+                                  std::vector<size_t> primary_keys)
     : columns_(std::move(columns)), primary_keys_(std::move(primary_keys)) {
   for (size_t i = 0; i < columns_.size(); i++) {
     PERFETTO_CHECK(columns_[i].index() == i);
@@ -241,10 +244,11 @@
   }
 }
 
-SqliteTable::Schema::Schema(const Schema&) = default;
-SqliteTable::Schema& SqliteTable::Schema::operator=(const Schema&) = default;
+SqliteTableLegacy::Schema::Schema(const Schema&) = default;
+SqliteTableLegacy::Schema& SqliteTableLegacy::Schema::operator=(const Schema&) =
+    default;
 
-std::string SqliteTable::Schema::ToCreateTableStmt() const {
+std::string SqliteTableLegacy::Schema::ToCreateTableStmt() const {
   std::string stmt = "CREATE TABLE x(";
   for (size_t i = 0; i < columns_.size(); ++i) {
     const Column& col = columns_[i];
@@ -279,7 +283,7 @@
 TypedSqliteTableBase::~TypedSqliteTableBase() = default;
 
 base::Status TypedSqliteTableBase::DeclareAndAssignVtab(
-    std::unique_ptr<SqliteTable> table,
+    std::unique_ptr<SqliteTableLegacy> table,
     sqlite3_vtab** tab) {
   auto create_stmt = table->schema().ToCreateTableStmt();
   PERFETTO_DLOG("Create table statement: %s", create_stmt.c_str());
@@ -289,7 +293,7 @@
 }
 
 int TypedSqliteTableBase::xDestroy(sqlite3_vtab* t) {
-  auto* table = static_cast<SqliteTable*>(t);
+  auto* table = static_cast<SqliteTableLegacy*>(t);
   table->engine_->OnSqliteTableDestroyed(table->name_);
   delete table;
   return SQLITE_OK;
@@ -309,7 +313,7 @@
 
   // SQLite guarantees that argv[2] contains the name of the table.
   std::string table_name = argv[2];
-  base::StatusOr<std::unique_ptr<SqliteTable>> table =
+  base::StatusOr<std::unique_ptr<SqliteTableLegacy>> table =
       xArg->engine->RestoreSqliteTable(table_name);
   if (!table.status().ok()) {
     *pzErr = sqlite3_mprintf("%s", table.status().c_message());
@@ -326,7 +330,7 @@
 int TypedSqliteTableBase::xDisconnectSaveTable(sqlite3_vtab* t) {
   auto* table = static_cast<TypedSqliteTableBase*>(t);
   base::Status status = table->engine_->SaveSqliteTable(
-      table->name(), std::unique_ptr<SqliteTable>(table));
+      table->name(), std::unique_ptr<SqliteTableLegacy>(table));
   return table->SetStatusAndReturn(status);
 }
 
@@ -423,7 +427,7 @@
       });
 
   auto out_qc_str = qc.ToNewSqlite3String();
-  if (SqliteTable::debug) {
+  if (SqliteTableLegacy::debug) {
     PERFETTO_LOG(
         "[%s::BestIndex] constraints=%s orderByConsumed=%d estimatedCost=%f "
         "estimatedRows=%" PRId64,
diff --git a/src/trace_processor/sqlite/sqlite_table.h b/src/trace_processor/sqlite/sqlite_table.h
index b2a7e40..d3b1c32 100644
--- a/src/trace_processor/sqlite/sqlite_table.h
+++ b/src/trace_processor/sqlite/sqlite_table.h
@@ -35,9 +35,9 @@
 // Abstract base class representing a SQLite virtual table. Implements the
 // common bookeeping required across all tables and allows subclasses to
 // implement a friendlier API than that required by SQLite.
-class SqliteTable : public sqlite3_vtab {
+class SqliteTableLegacy : public sqlite3_vtab {
  public:
-  // Custom opcodes used by subclasses of SqliteTable.
+  // Custom opcodes used by subclasses of SqliteTableLegacy.
   // Stored here as we need a central repository of opcodes to prevent clashes
   // between different sub-classes.
   enum CustomFilterOpcode {
@@ -82,7 +82,7 @@
       kSame = 1,
     };
 
-    explicit BaseCursor(SqliteTable* table);
+    explicit BaseCursor(SqliteTableLegacy* table);
     virtual ~BaseCursor();
 
     // Methods to be implemented by derived table classes.
@@ -105,7 +105,7 @@
     // Used to extract the value from the column at index |N|.
     void Column(sqlite3_context* context, int N);
 
-    SqliteTable* table() const { return table_; }
+    SqliteTableLegacy* table() const { return table_; }
 
    protected:
     BaseCursor(BaseCursor&) = delete;
@@ -115,7 +115,7 @@
     BaseCursor& operator=(BaseCursor&&) = default;
 
    private:
-    SqliteTable* table_ = nullptr;
+    SqliteTableLegacy* table_ = nullptr;
   };
 
   // The schema of the table. Created by subclasses to allow the table class to
@@ -157,7 +157,7 @@
   };
 
   // Public for unique_ptr destructor calls.
-  virtual ~SqliteTable();
+  virtual ~SqliteTableLegacy();
 
   // When set it logs all BestIndex and Filter actions on the console.
   static bool debug;
@@ -188,7 +188,7 @@
     int64_t estimated_rows = 0;
   };
 
-  SqliteTable();
+  SqliteTableLegacy();
 
   // Methods to be implemented by derived table classes.
   virtual base::Status Init(int argc, const char* const* argv, Schema*) = 0;
@@ -214,8 +214,8 @@
   friend class TypedSqliteTable;
   friend class TypedSqliteTableBase;
 
-  SqliteTable(const SqliteTable&) = delete;
-  SqliteTable& operator=(const SqliteTable&) = delete;
+  SqliteTableLegacy(const SqliteTableLegacy&) = delete;
+  SqliteTableLegacy& operator=(const SqliteTableLegacy&) = delete;
 
   // The engine class this table is registered with. Used for restoring/saving
   // the table.
@@ -238,7 +238,7 @@
   int best_index_num_ = 0;
 };
 
-class TypedSqliteTableBase : public SqliteTable {
+class TypedSqliteTableBase : public SqliteTableLegacy {
  protected:
   struct BaseModuleArg {
     sqlite3_module module;
@@ -262,8 +262,9 @@
   static int xOpen(sqlite3_vtab*, sqlite3_vtab_cursor**);
   static int xBestIndex(sqlite3_vtab*, sqlite3_index_info*);
 
-  static base::Status DeclareAndAssignVtab(std::unique_ptr<SqliteTable> table,
-                                           sqlite3_vtab** tab);
+  static base::Status DeclareAndAssignVtab(
+      std::unique_ptr<SqliteTableLegacy> table,
+      sqlite3_vtab** tab);
 
   base::Status InitInternal(SqliteEngine* engine,
                             int argc,
diff --git a/src/trace_processor/sqlite/sqlite_utils.cc b/src/trace_processor/sqlite/sqlite_utils.cc
index 04ddf9c..4e6277d 100644
--- a/src/trace_processor/sqlite/sqlite_utils.cc
+++ b/src/trace_processor/sqlite/sqlite_utils.cc
@@ -92,9 +92,10 @@
   return {reinterpret_cast<const wchar_t*>(sqlite3_value_text16(value)), count};
 }
 
-base::Status GetColumnsForTable(sqlite3* db,
-                                const std::string& raw_table_name,
-                                std::vector<SqliteTable::Column>& columns) {
+base::Status GetColumnsForTable(
+    sqlite3* db,
+    const std::string& raw_table_name,
+    std::vector<SqliteTableLegacy::Column>& columns) {
   PERFETTO_DCHECK(columns.empty());
   char sql[1024];
   const char kRawSql[] = "SELECT name, type from pragma_table_info(\"%s\")";
diff --git a/src/trace_processor/sqlite/sqlite_utils.h b/src/trace_processor/sqlite/sqlite_utils.h
index 7cdb1d1..f3196a8 100644
--- a/src/trace_processor/sqlite/sqlite_utils.h
+++ b/src/trace_processor/sqlite/sqlite_utils.h
@@ -30,7 +30,7 @@
 #include "perfetto/base/status.h"
 #include "perfetto/ext/base/status_or.h"
 #include "perfetto/trace_processor/basic_types.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/sqlite/sqlite_table.h"
 
 namespace perfetto::trace_processor::sqlite::utils {
@@ -137,6 +137,12 @@
   }
 }
 
+inline int SetError(sqlite3_vtab* tab, const char* status) {
+  sqlite3_free(tab->zErrMsg);
+  tab->zErrMsg = sqlite3_mprintf("%s", status);
+  return SQLITE_ERROR;
+}
+
 inline void SetError(sqlite3_context* ctx, const base::Status& status) {
   PERFETTO_CHECK(!status.ok());
   sqlite::result::Error(ctx, status.c_message());
@@ -167,9 +173,10 @@
                                  std::optional<const char*>&);
 
 // Returns the column names for the table named by |raw_table_name|.
-base::Status GetColumnsForTable(sqlite3* db,
-                                const std::string& raw_table_name,
-                                std::vector<SqliteTable::Column>& columns);
+base::Status GetColumnsForTable(
+    sqlite3* db,
+    const std::string& raw_table_name,
+    std::vector<SqliteTableLegacy::Column>& columns);
 
 // Reads a `SQLITE_TEXT` value and returns it as a wstring (UTF-16) in the
 // default byte order. `value` must be a `SQLITE_TEXT`.
diff --git a/src/trace_processor/sqlite/sqlite_utils_unittest.cc b/src/trace_processor/sqlite/sqlite_utils_unittest.cc
index 4347317..4e879d3 100644
--- a/src/trace_processor/sqlite/sqlite_utils_unittest.cc
+++ b/src/trace_processor/sqlite/sqlite_utils_unittest.cc
@@ -52,7 +52,7 @@
 
 TEST_F(GetColumnsForTableTest, ValidInput) {
   RunStatement("CREATE TABLE foo (name STRING, ts INT, dur INT);");
-  std::vector<SqliteTable::Column> columns;
+  std::vector<SqliteTableLegacy::Column> columns;
   auto status = sqlite::utils::GetColumnsForTable(*db_, "foo", columns);
   ASSERT_TRUE(status.ok());
 }
@@ -62,13 +62,13 @@
   // doesn't recognise. This just ensures that the query fails rather than
   // crashing.
   RunStatement("CREATE TABLE foo (name NUM, ts INT, dur INT);");
-  std::vector<SqliteTable::Column> columns;
+  std::vector<SqliteTableLegacy::Column> columns;
   auto status = sqlite::utils::GetColumnsForTable(*db_, "foo", columns);
   ASSERT_FALSE(status.ok());
 }
 
 TEST_F(GetColumnsForTableTest, UnknownTableName) {
-  std::vector<SqliteTable::Column> columns;
+  std::vector<SqliteTableLegacy::Column> columns;
   auto status =
       sqlite::utils::GetColumnsForTable(*db_, "unknowntable", columns);
   ASSERT_FALSE(status.ok());
diff --git a/src/trace_processor/sqlite/stats_table.cc b/src/trace_processor/sqlite/stats_table.cc
index d93057d..4c3aa6d 100644
--- a/src/trace_processor/sqlite/stats_table.cc
+++ b/src/trace_processor/sqlite/stats_table.cc
@@ -16,78 +16,117 @@
 
 #include "src/trace_processor/sqlite/stats_table.h"
 
+#include <sqlite3.h>
 #include <memory>
 
-#include "perfetto/base/status.h"
-#include "perfetto/trace_processor/basic_types.h"
-#include "src/trace_processor/sqlite/query_constraints.h"
-#include "src/trace_processor/sqlite/sqlite_result.h"
-#include "src/trace_processor/sqlite/sqlite_table.h"
-#include "src/trace_processor/sqlite/sqlite_utils.h"
+#include "perfetto/base/logging.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/storage/stats.h"
 #include "src/trace_processor/storage/trace_storage.h"
 
 namespace perfetto::trace_processor {
 
-StatsTable::StatsTable(sqlite3*, const TraceStorage* storage)
-    : storage_(storage) {}
-
-StatsTable::~StatsTable() = default;
-
-base::Status StatsTable::Init(int, const char* const*, Schema* schema) {
-  *schema = Schema(
-      {
-          SqliteTable::Column(Column::kName, "name", SqlValue::Type::kString),
-          // Calling a column "index" causes sqlite to silently fail, hence idx.
-          SqliteTable::Column(Column::kIndex, "idx", SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kSeverity, "severity",
-                              SqlValue::Type::kString),
-          SqliteTable::Column(Column::kSource, "source",
-                              SqlValue::Type::kString),
-          SqliteTable::Column(Column::kValue, "value", SqlValue::Type::kLong),
-          SqliteTable::Column(Column::kDescription, "description",
-                              SqlValue::Type::kString),
-      },
-      {Column::kName});
-  return base::OkStatus();
-}
-
-std::unique_ptr<SqliteTable::BaseCursor> StatsTable::CreateCursor() {
-  return std::unique_ptr<SqliteTable::BaseCursor>(new Cursor(this));
-}
-
-int StatsTable::BestIndex(const QueryConstraints&, BestIndexInfo*) {
+int StatsModule::Connect(sqlite3* db,
+                         void* aux,
+                         int,
+                         const char* const*,
+                         sqlite3_vtab** vtab,
+                         char**) {
+  static constexpr char kSchema[] = R"(
+    CREATE TABLE x(
+      name TEXT,
+      idx BIGINT,
+      severity TEXT,
+      source TEXT,
+      value BIGINT,
+      description TEXT,
+      PRIMARY KEY(name)
+    ) WITHOUT ROWID
+  )";
+  if (int ret = sqlite3_declare_vtab(db, kSchema); ret != SQLITE_OK) {
+    return ret;
+  }
+  std::unique_ptr<Vtab> res = std::make_unique<Vtab>();
+  res->storage = GetContext(aux);
+  *vtab = res.release();
   return SQLITE_OK;
 }
 
-StatsTable::Cursor::Cursor(StatsTable* table)
-    : SqliteTable::BaseCursor(table),
-      table_(table),
-      storage_(table->storage_) {}
-
-StatsTable::Cursor::~Cursor() = default;
-
-base::Status StatsTable::Cursor::Filter(const QueryConstraints&,
-                                        sqlite3_value**,
-                                        FilterHistory) {
-  *this = Cursor(table_);
-  return base::OkStatus();
+int StatsModule::Disconnect(sqlite3_vtab* vtab) {
+  delete GetVtab(vtab);
+  return SQLITE_OK;
 }
 
-base::Status StatsTable::Cursor::Column(sqlite3_context* ctx, int N) {
+int StatsModule::BestIndex(sqlite3_vtab*, sqlite3_index_info*) {
+  return SQLITE_OK;
+}
+
+int StatsModule::Open(sqlite3_vtab* raw_vtab, sqlite3_vtab_cursor** cursor) {
+  std::unique_ptr<Cursor> c = std::make_unique<Cursor>();
+  c->storage = GetVtab(raw_vtab)->storage;
+  *cursor = c.release();
+  return SQLITE_OK;
+}
+
+int StatsModule::Close(sqlite3_vtab_cursor* cursor) {
+  delete GetCursor(cursor);
+  return SQLITE_OK;
+}
+
+int StatsModule::Filter(sqlite3_vtab_cursor* cursor,
+                        int,
+                        const char*,
+                        int,
+                        sqlite3_value**) {
+  auto* c = GetCursor(cursor);
+  c->key = {};
+  c->it = {};
+  return SQLITE_OK;
+}
+
+int StatsModule::Next(sqlite3_vtab_cursor* cursor) {
+  static_assert(stats::kTypes[0] == stats::kSingle,
+                "the first stats entry cannot be indexed");
+
+  auto* c = GetCursor(cursor);
+  const auto* cur_entry = &c->storage->stats()[c->key];
+  if (stats::kTypes[c->key] == stats::kIndexed) {
+    if (++c->it != cur_entry->indexed_values.end()) {
+      return SQLITE_OK;
+    }
+  }
+  while (++c->key < stats::kNumKeys) {
+    cur_entry = &c->storage->stats()[c->key];
+    c->it = cur_entry->indexed_values.begin();
+    if (stats::kTypes[c->key] == stats::kSingle ||
+        !cur_entry->indexed_values.empty()) {
+      break;
+    }
+  }
+  return SQLITE_OK;
+}
+
+int StatsModule::Eof(sqlite3_vtab_cursor* cursor) {
+  return GetCursor(cursor)->key >= stats::kNumKeys;
+}
+
+int StatsModule::Column(sqlite3_vtab_cursor* cursor,
+                        sqlite3_context* ctx,
+                        int N) {
+  auto* c = GetCursor(cursor);
   switch (N) {
     case Column::kName:
-      sqlite::result::StaticString(ctx, stats::kNames[key_]);
+      sqlite::result::StaticString(ctx, stats::kNames[c->key]);
       break;
     case Column::kIndex:
-      if (stats::kTypes[key_] == stats::kIndexed) {
-        sqlite::result::Long(ctx, index_->first);
+      if (stats::kTypes[c->key] == stats::kIndexed) {
+        sqlite::result::Long(ctx, c->it->first);
       } else {
         sqlite::result::Null(ctx);
       }
       break;
     case Column::kSeverity:
-      switch (stats::kSeverities[key_]) {
+      switch (stats::kSeverities[c->key]) {
         case stats::kInfo:
           sqlite::result::StaticString(ctx, "info");
           break;
@@ -100,7 +139,7 @@
       }
       break;
     case Column::kSource:
-      switch (stats::kSources[key_]) {
+      switch (stats::kSources[c->key]) {
         case stats::kTrace:
           sqlite::result::StaticString(ctx, "trace");
           break;
@@ -110,44 +149,24 @@
       }
       break;
     case Column::kValue:
-      if (stats::kTypes[key_] == stats::kIndexed) {
-        sqlite::result::Long(ctx, index_->second);
+      if (stats::kTypes[c->key] == stats::kIndexed) {
+        sqlite::result::Long(ctx, c->it->second);
       } else {
-        sqlite::result::Long(ctx, storage_->stats()[key_].value);
+        sqlite::result::Long(ctx, c->storage->stats()[c->key].value);
       }
       break;
     case Column::kDescription:
-      sqlite::result::StaticString(ctx, stats::kDescriptions[key_]);
+      sqlite::result::StaticString(ctx, stats::kDescriptions[c->key]);
       break;
     default:
       PERFETTO_FATAL("Unknown column %d", N);
       break;
   }
-  return base::OkStatus();
+  return SQLITE_OK;
 }
 
-base::Status StatsTable::Cursor::Next() {
-  static_assert(stats::kTypes[0] == stats::kSingle,
-                "the first stats entry cannot be indexed");
-  const auto* cur_entry = &storage_->stats()[key_];
-  if (stats::kTypes[key_] == stats::kIndexed) {
-    if (++index_ != cur_entry->indexed_values.end()) {
-      return base::OkStatus();
-    }
-  }
-  while (++key_ < stats::kNumKeys) {
-    cur_entry = &storage_->stats()[key_];
-    index_ = cur_entry->indexed_values.begin();
-    if (stats::kTypes[key_] == stats::kSingle ||
-        !cur_entry->indexed_values.empty()) {
-      break;
-    }
-  }
-  return base::OkStatus();
-}
-
-bool StatsTable::Cursor::Eof() {
-  return key_ >= stats::kNumKeys;
+int StatsModule::Rowid(sqlite3_vtab_cursor*, sqlite_int64*) {
+  return SQLITE_ERROR;
 }
 
 }  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/sqlite/stats_table.h b/src/trace_processor/sqlite/stats_table.h
index 2824cb6..bff7ae8 100644
--- a/src/trace_processor/sqlite/stats_table.h
+++ b/src/trace_processor/sqlite/stats_table.h
@@ -17,61 +17,56 @@
 #ifndef SRC_TRACE_PROCESSOR_SQLITE_STATS_TABLE_H_
 #define SRC_TRACE_PROCESSOR_SQLITE_STATS_TABLE_H_
 
-#include <limits>
-#include <memory>
+#include <cstddef>
 
-#include "src/trace_processor/sqlite/sqlite_table.h"
-#include "src/trace_processor/storage/stats.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_module.h"
 #include "src/trace_processor/storage/trace_storage.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
 // The stats table contains diagnostic info and errors that are either:
 // - Collected at trace time (e.g., ftrace buffer overruns).
 // - Generated at parsing time (e.g., clock events out-of-order).
-class StatsTable final
-    : public TypedSqliteTable<StatsTable, const TraceStorage*> {
- public:
-  enum Column { kName = 0, kIndex, kSeverity, kSource, kValue, kDescription };
-  class Cursor final : public SqliteTable::BaseCursor {
-   public:
-    explicit Cursor(StatsTable*);
-    ~Cursor() final;
-
-    // Implementation of SqliteTable::Cursor.
-    base::Status Filter(const QueryConstraints&,
-                        sqlite3_value**,
-                        FilterHistory);
-    base::Status Next();
-    bool Eof();
-    base::Status Column(sqlite3_context*, int N);
-
-   private:
-    Cursor(Cursor&) = delete;
-    Cursor& operator=(const Cursor&) = delete;
-
-    Cursor(Cursor&&) noexcept = default;
-    Cursor& operator=(Cursor&&) = default;
-
-    StatsTable* table_ = nullptr;
-    const TraceStorage* storage_ = nullptr;
-    size_t key_ = 0;
-    TraceStorage::Stats::IndexMap::const_iterator index_{};
+struct StatsModule : sqlite::Module<StatsModule> {
+  using Context = TraceStorage;
+  struct Vtab : sqlite::Module<StatsModule>::Vtab {
+    TraceStorage* storage = nullptr;
   };
+  struct Cursor : sqlite::Module<StatsModule>::Cursor {
+    const TraceStorage* storage = nullptr;
+    size_t key = 0;
+    TraceStorage::Stats::IndexMap::const_iterator it{};
+  };
+  enum Column { kName = 0, kIndex, kSeverity, kSource, kValue, kDescription };
 
-  StatsTable(sqlite3*, const TraceStorage*);
-  ~StatsTable() final;
+  static constexpr auto kType = kEponymousOnly;
+  static constexpr bool kSupportsWrites = false;
+  static constexpr bool kDoesOverloadFunctions = false;
 
-  // Table implementation.
-  util::Status Init(int, const char* const*, SqliteTable::Schema*) final;
-  std::unique_ptr<SqliteTable::BaseCursor> CreateCursor() final;
-  int BestIndex(const QueryConstraints&, BestIndexInfo*) final;
+  static int Connect(sqlite3*,
+                     void*,
+                     int,
+                     const char* const*,
+                     sqlite3_vtab**,
+                     char**);
+  static int Disconnect(sqlite3_vtab*);
 
- private:
-  const TraceStorage* const storage_;
+  static int BestIndex(sqlite3_vtab*, sqlite3_index_info*);
+
+  static int Open(sqlite3_vtab*, sqlite3_vtab_cursor**);
+  static int Close(sqlite3_vtab_cursor*);
+
+  static int Filter(sqlite3_vtab_cursor*,
+                    int,
+                    const char*,
+                    int,
+                    sqlite3_value**);
+  static int Next(sqlite3_vtab_cursor*);
+  static int Eof(sqlite3_vtab_cursor*);
+  static int Column(sqlite3_vtab_cursor*, sqlite3_context*, int);
+  static int Rowid(sqlite3_vtab_cursor*, sqlite_int64*);
 };
-}  // namespace trace_processor
-}  // namespace perfetto
+
+}  // namespace perfetto::trace_processor
 
 #endif  // SRC_TRACE_PROCESSOR_SQLITE_STATS_TABLE_H_
diff --git a/src/trace_processor/trace_processor.cc b/src/trace_processor/trace_processor.cc
index cd6ebda..4d022f7 100644
--- a/src/trace_processor/trace_processor.cc
+++ b/src/trace_processor/trace_processor.cc
@@ -36,7 +36,7 @@
 void EnableSQLiteVtableDebugging() {
   // This level of indirection is required to avoid clients to depend on table.h
   // which in turn requires sqlite headers.
-  SqliteTable::debug = true;
+  SqliteTableLegacy::debug = true;
 }
 
 }  // namespace trace_processor
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index 9ac4a5c..bfd34d0 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -23,6 +23,7 @@
 #include <cstdint>
 #include <limits>
 #include <memory>
+#include <optional>
 #include <string>
 #include <unordered_map>
 #include <utility>
@@ -95,6 +96,8 @@
 #include "src/trace_processor/perfetto_sql/intrinsics/table_functions/table_info.h"
 #include "src/trace_processor/perfetto_sql/prelude/tables_views.h"
 #include "src/trace_processor/perfetto_sql/stdlib/stdlib.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h"
+#include "src/trace_processor/sqlite/bindings/sqlite_result.h"
 #include "src/trace_processor/sqlite/scoped_db.h"
 #include "src/trace_processor/sqlite/sql_source.h"
 #include "src/trace_processor/sqlite/sql_stats_table.h"
@@ -171,86 +174,88 @@
   }
 }
 
-struct ValueAtMaxTsContext {
-  bool initialized;
-  int value_type;
+class ValueAtMaxTs : public SqliteAggregateFunction {
+ public:
+  struct Context {
+    bool initialized;
+    int value_type;
 
-  int64_t max_ts;
-  int64_t int_value_at_max_ts;
-  double double_value_at_max_ts;
-};
+    int64_t max_ts;
+    int64_t int_value_at_max_ts;
+    double double_value_at_max_ts;
+  };
 
-void ValueAtMaxTsStep(sqlite3_context* ctx, int, sqlite3_value** argv) {
-  sqlite3_value* ts = argv[0];
-  sqlite3_value* value = argv[1];
+  static void Step(sqlite3_context* ctx, int, sqlite3_value** argv) {
+    sqlite3_value* ts = argv[0];
+    sqlite3_value* value = argv[1];
 
-  // Note that sqlite3_aggregate_context zeros the memory for us so all the
-  // variables of the struct should be zero.
-  auto* fn_ctx = reinterpret_cast<ValueAtMaxTsContext*>(
-      sqlite3_aggregate_context(ctx, sizeof(ValueAtMaxTsContext)));
+    // Note that sqlite3_aggregate_context zeros the memory for us so all the
+    // variables of the struct should be zero.
+    auto* fn_ctx = reinterpret_cast<Context*>(
+        sqlite3_aggregate_context(ctx, sizeof(Context)));
 
-  // For performance reasons, we only do the check for the type of ts and value
-  // on the first call of the function.
-  if (PERFETTO_UNLIKELY(!fn_ctx->initialized)) {
+    // For performance reasons, we only do the check for the type of ts and
+    // value on the first call of the function.
+    if (PERFETTO_UNLIKELY(!fn_ctx->initialized)) {
+      if (sqlite3_value_type(ts) != SQLITE_INTEGER) {
+        return sqlite::result::Error(
+            ctx, "VALUE_AT_MAX_TS: ts passed was not an integer");
+      }
+
+      fn_ctx->value_type = sqlite3_value_type(value);
+      if (fn_ctx->value_type != SQLITE_INTEGER &&
+          fn_ctx->value_type != SQLITE_FLOAT) {
+        return sqlite::result::Error(
+            ctx, "VALUE_AT_MAX_TS: value passed was not an integer or float");
+      }
+
+      fn_ctx->max_ts = std::numeric_limits<int64_t>::min();
+      fn_ctx->initialized = true;
+    }
+
+    // On dcheck builds however, we check every passed ts and value.
+#if PERFETTO_DCHECK_IS_ON()
     if (sqlite3_value_type(ts) != SQLITE_INTEGER) {
       return sqlite::result::Error(
           ctx, "VALUE_AT_MAX_TS: ts passed was not an integer");
     }
-
-    fn_ctx->value_type = sqlite3_value_type(value);
-    if (fn_ctx->value_type != SQLITE_INTEGER &&
-        fn_ctx->value_type != SQLITE_FLOAT) {
+    if (sqlite3_value_type(value) != fn_ctx->value_type) {
       return sqlite::result::Error(
-          ctx, "VALUE_AT_MAX_TS: value passed was not an integer or float");
+          ctx, "VALUE_AT_MAX_TS: value type is inconsistent");
     }
-
-    fn_ctx->max_ts = std::numeric_limits<int64_t>::min();
-    fn_ctx->initialized = true;
-  }
-
-  // On dcheck builds however, we check every passed ts and value.
-#if PERFETTO_DCHECK_IS_ON()
-  if (sqlite3_value_type(ts) != SQLITE_INTEGER) {
-    return sqlite::result::Error(
-        ctx, "VALUE_AT_MAX_TS: ts passed was not an integer");
-  }
-  if (sqlite3_value_type(value) != fn_ctx->value_type) {
-    return sqlite::result::Error(ctx,
-                                 "VALUE_AT_MAX_TS: value type is inconsistent");
-  }
 #endif
 
-  int64_t ts_int = sqlite3_value_int64(ts);
-  if (PERFETTO_LIKELY(fn_ctx->max_ts <= ts_int)) {
-    fn_ctx->max_ts = ts_int;
+    int64_t ts_int = sqlite3_value_int64(ts);
+    if (PERFETTO_LIKELY(fn_ctx->max_ts <= ts_int)) {
+      fn_ctx->max_ts = ts_int;
 
-    if (fn_ctx->value_type == SQLITE_INTEGER) {
-      fn_ctx->int_value_at_max_ts = sqlite3_value_int64(value);
-    } else {
-      fn_ctx->double_value_at_max_ts = sqlite3_value_double(value);
+      if (fn_ctx->value_type == SQLITE_INTEGER) {
+        fn_ctx->int_value_at_max_ts = sqlite3_value_int64(value);
+      } else {
+        fn_ctx->double_value_at_max_ts = sqlite3_value_double(value);
+      }
     }
   }
-}
 
-void ValueAtMaxTsFinal(sqlite3_context* ctx) {
-  ValueAtMaxTsContext* fn_ctx =
-      reinterpret_cast<ValueAtMaxTsContext*>(sqlite3_aggregate_context(ctx, 0));
-  if (!fn_ctx) {
-    sqlite::result::Null(ctx);
-    return;
+  static void Final(sqlite3_context* ctx) {
+    Context* fn_ctx =
+        reinterpret_cast<Context*>(sqlite3_aggregate_context(ctx, 0));
+    if (!fn_ctx) {
+      sqlite::result::Null(ctx);
+      return;
+    }
+    if (fn_ctx->value_type == SQLITE_INTEGER) {
+      sqlite::result::Long(ctx, fn_ctx->int_value_at_max_ts);
+    } else {
+      sqlite::result::Double(ctx, fn_ctx->double_value_at_max_ts);
+    }
   }
-  if (fn_ctx->value_type == SQLITE_INTEGER) {
-    sqlite::result::Long(ctx, fn_ctx->int_value_at_max_ts);
-  } else {
-    sqlite::result::Double(ctx, fn_ctx->double_value_at_max_ts);
-  }
-}
+};
 
-void RegisterValueAtMaxTsFunction(sqlite3* db) {
-  auto ret = sqlite3_create_function_v2(
-      db, "VALUE_AT_MAX_TS", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, nullptr,
-      nullptr, &ValueAtMaxTsStep, &ValueAtMaxTsFinal, nullptr);
-  if (ret) {
+void RegisterValueAtMaxTsFunction(PerfettoSqlEngine& engine) {
+  base::Status status = engine.RegisterSqliteAggregateFunction<ValueAtMaxTs>(
+      "VALUE_AT_MAX_TS", 2, nullptr);
+  if (!status.ok()) {
     PERFETTO_ELOG("Error initializing VALUE_AT_MAX_TS");
   }
 }
@@ -693,7 +698,7 @@
   // Old style function registration.
   // TODO(lalitm): migrate this over to using RegisterFunction once aggregate
   // functions are supported.
-  RegisterValueAtMaxTsFunction(db);
+  RegisterValueAtMaxTsFunction(*engine_);
   {
     base::Status status = RegisterLastNonNullFunction(*engine_);
     if (!status.ok())
@@ -705,7 +710,7 @@
       PERFETTO_ELOG("%s", status.c_message());
   }
   {
-    base::Status status = PprofFunctions::Register(db, &context_);
+    base::Status status = PprofFunctions::Register(*engine_, &context_);
     if (!status.ok())
       PERFETTO_ELOG("%s", status.c_message());
   }
@@ -725,20 +730,20 @@
       PERFETTO_ELOG("%s", status.c_message());
   }
 
-  const TraceStorage* storage = context_.storage.get();
+  TraceStorage* storage = context_.storage.get();
 
   // Operator tables.
   engine_->sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
-      "span_join", engine_.get(), SqliteTable::TableType::kExplicitCreate,
+      "span_join", engine_.get(), SqliteTableLegacy::TableType::kExplicitCreate,
       false);
   engine_->sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
-      "span_left_join", engine_.get(), SqliteTable::TableType::kExplicitCreate,
-      false);
+      "span_left_join", engine_.get(),
+      SqliteTableLegacy::TableType::kExplicitCreate, false);
   engine_->sqlite_engine()->RegisterVirtualTableModule<SpanJoinOperatorTable>(
-      "span_outer_join", engine_.get(), SqliteTable::TableType::kExplicitCreate,
-      false);
-  engine_->sqlite_engine()->RegisterVirtualTableModule<WindowOperatorTable>(
-      "window", storage, SqliteTable::TableType::kExplicitCreate, true);
+      "span_outer_join", engine_.get(),
+      SqliteTableLegacy::TableType::kExplicitCreate, false);
+  engine_->sqlite_engine()->RegisterVirtualTableModule<WindowOperatorModule>(
+      "window", std::make_unique<WindowOperatorModule::Context>());
 
   // Initalize the tables and views in the prelude.
   InitializePreludeTablesViews(db);
@@ -753,14 +758,12 @@
   }
 
   // Register metrics functions.
-  // TODO(lalitm): migrate this over to using RegisterFunction once aggregate
-  // functions are supported.
   {
-    auto ret = sqlite3_create_function_v2(
-        db, "RepeatedField", 1, SQLITE_UTF8, nullptr, nullptr,
-        metrics::RepeatedFieldStep, metrics::RepeatedFieldFinal, nullptr);
-    if (ret)
-      PERFETTO_FATAL("Error initializing RepeatedField");
+    base::Status status =
+        engine_->RegisterSqliteAggregateFunction<metrics::RepeatedField>(
+            "RepeatedField", 1, nullptr);
+    if (!status.ok())
+      PERFETTO_ELOG("%s", status.c_message());
   }
 
   RegisterFunction<metrics::NullIfEmpty>(engine_.get(), "NULL_IF_EMPTY", 1);
@@ -772,10 +775,10 @@
           metrics::RunMetric::Context{engine_.get(), &sql_metrics_}));
 
   // Legacy tables.
-  engine_->sqlite_engine()->RegisterVirtualTableModule<SqlStatsTable>(
-      "sqlstats", storage, SqliteTable::TableType::kEponymousOnly, false);
-  engine_->sqlite_engine()->RegisterVirtualTableModule<StatsTable>(
-      "stats", storage, SqliteTable::TableType::kEponymousOnly, false);
+  engine_->sqlite_engine()->RegisterVirtualTableModule<SqlStatsModule>(
+      "sqlstats", storage);
+  engine_->sqlite_engine()->RegisterVirtualTableModule<StatsModule>("stats",
+                                                                    storage);
 
   // New style db-backed tables.
   // Note: if adding a table here which might potentially contain many rows
diff --git a/src/trace_redaction/scrub_process_trees.cc b/src/trace_redaction/scrub_process_trees.cc
index 449a056..5c54d83 100644
--- a/src/trace_redaction/scrub_process_trees.cc
+++ b/src/trace_redaction/scrub_process_trees.cc
@@ -16,9 +16,11 @@
 
 #include "src/trace_redaction/scrub_process_trees.h"
 
+#include <cstdint>
 #include <string>
 
 #include "perfetto/base/status.h"
+#include "perfetto/protozero/field.h"
 #include "perfetto/protozero/scattered_heap_buffer.h"
 #include "src/trace_redaction/proto_util.h"
 #include "src/trace_redaction/trace_redaction_framework.h"
diff --git a/src/trace_redaction/scrub_process_trees_integrationtest.cc b/src/trace_redaction/scrub_process_trees_integrationtest.cc
index 42328c1..c1ca7b8 100644
--- a/src/trace_redaction/scrub_process_trees_integrationtest.cc
+++ b/src/trace_redaction/scrub_process_trees_integrationtest.cc
@@ -42,18 +42,18 @@
 constexpr std::string_view kProcessName =
     "com.Unity.com.unity.multiplayer.samples.coop";
 
+}  // namespace
+
 class ScrubProcessTreesIntegrationTest : public testing::Test {
  protected:
   void SetUp() override {
-    src_trace_ = base::GetTestDataPath(std::string(kTracePath));
-
     // ScrubProcessTrees depends on:
-    //    - FindPackageUid    (uid)
-    //    - OptimizeTimeline  (sealed + optimized timeline)
+    //    - FindPackageUid    (creates: uid)
+    //    - OptimizeTimeline  (creates: optimized timeline)
     //
     // OptimizeTimeline depends on:
-    //    - FindPackageUid (uid)
-    //    - BuildTimeline  (timeline)
+    //    - FindPackageUid (uses: uid)
+    //    - BuildTimeline  (uses: timeline)
     //
     // BuildTimeline depends on.... nothing
     // FindPackageUid depends on... nothing
@@ -66,11 +66,52 @@
     // In this case, the process and package have the same name.
     context_.package_name = kProcessName;
 
+    src_trace_ = base::GetTestDataPath(std::string(kTracePath));
+
     dest_trace_ = tmp_dir_.AbsolutePath("dst.pftrace");
     tmp_dir_.TrackFile("dst.pftrace");
   }
 
-  static base::StatusOr<std::string> ReadRawTrace(const std::string& path) {
+  base::Status Redact() {
+    return redactor_.Redact(src_trace_, dest_trace_, &context_);
+  }
+
+  base::StatusOr<std::string> LoadOriginal() const {
+    return ReadRawTrace(src_trace_);
+  }
+
+  base::StatusOr<std::string> LoadRedacted() const {
+    return ReadRawTrace(dest_trace_);
+  }
+
+  std::vector<std::string> CollectProcessNames(
+      protos::pbzero::Trace::Decoder trace) const {
+    std::vector<std::string> names;
+
+    for (auto packet_it = trace.packet(); packet_it; ++packet_it) {
+      protos::pbzero::TracePacket::Decoder packet(*packet_it);
+
+      if (!packet.has_process_tree()) {
+        continue;
+      }
+
+      protos::pbzero::ProcessTree::Decoder process_tree(packet.process_tree());
+
+      for (auto process_it = process_tree.processes(); process_it;
+           ++process_it) {
+        protos::pbzero::ProcessTree::Process::Decoder process(*process_it);
+
+        if (process.has_cmdline()) {
+          names.push_back(process.cmdline()->as_std_string());
+        }
+      }
+    }
+
+    return names;
+  }
+
+ private:
+  base::StatusOr<std::string> ReadRawTrace(const std::string& path) const {
     std::string redacted_buffer;
 
     if (base::ReadFile(path, &redacted_buffer)) {
@@ -80,55 +121,34 @@
     return base::ErrStatus("Failed to read %s", path.c_str());
   }
 
-  std::string src_trace_;
-  std::string dest_trace_;
+  Context context_;
+  TraceRedactor redactor_;
 
   base::TmpDirTree tmp_dir_;
 
-  Context context_;
-  TraceRedactor redactor_;
+  std::string src_trace_;
+  std::string dest_trace_;
 };
 
 TEST_F(ScrubProcessTreesIntegrationTest, RemovesProcessNamesFromProcessTrees) {
-  ASSERT_OK(redactor_.Redact(src_trace_, dest_trace_, &context_));
-  ASSERT_OK_AND_ASSIGN(auto redacted_buffer, ReadRawTrace(dest_trace_));
+  ASSERT_OK(Redact());
 
-  protos::pbzero::Trace::Decoder trace(redacted_buffer);
+  auto original_trace_str = LoadOriginal();
+  ASSERT_OK(original_trace_str);
 
-  for (auto packet_it = trace.packet(); packet_it; ++packet_it) {
-    protos::pbzero::TracePacket::Decoder packet(*packet_it);
+  auto redacted_trace_str = LoadRedacted();
+  ASSERT_OK(redacted_trace_str);
 
-    if (!packet.has_process_tree()) {
-      continue;
-    }
+  protos::pbzero::Trace::Decoder original_trace(original_trace_str.value());
+  auto original_processes = CollectProcessNames(std::move(original_trace));
 
-    protos::pbzero::ProcessTree::Decoder process_tree(packet.process_tree());
+  ASSERT_GT(original_processes.size(), 1u);
 
-    for (auto process_it = process_tree.processes(); process_it; ++process_it) {
-      protos::pbzero::ProcessTree::Process::Decoder process(*process_it);
+  protos::pbzero::Trace::Decoder redacted_trace(redacted_trace_str.value());
+  auto redacted_processes = CollectProcessNames(std::move(redacted_trace));
 
-      std::vector<std::string> cmdline;
-      for (auto cmd_it = process.cmdline(); cmd_it; ++cmd_it) {
-        cmdline.push_back(cmd_it->as_std_string());
-      }
-
-      // It's okay to be empty.
-      if (cmdline.empty()) {
-        continue;
-      }
-
-      if (cmdline.size() == 1) {
-        ASSERT_EQ(cmdline[0], kProcessName);
-        continue;
-      }
-
-      // If there are more than
-      for (const auto& token : cmdline) {
-        ASSERT_TRUE(token.empty());
-      }
-    }
-  }
+  ASSERT_EQ(redacted_processes.size(), 1u);
+  ASSERT_EQ(redacted_processes.at(0), kProcessName);
 }
 
-}  // namespace
 }  // namespace perfetto::trace_redaction
diff --git a/src/traced/probes/ftrace/cpu_reader.cc b/src/traced/probes/ftrace/cpu_reader.cc
index 462c992..66b9b1b 100644
--- a/src/traced/probes/ftrace/cpu_reader.cc
+++ b/src/traced/probes/ftrace/cpu_reader.cc
@@ -297,13 +297,16 @@
   if (pages_read == 0)
     return pages_read;
 
+  uint64_t last_read_ts = last_read_event_ts_;
   for (FtraceDataSource* data_source : started_data_sources) {
+    last_read_ts = last_read_event_ts_;
     ProcessPagesForDataSource(
         data_source->trace_writer(), data_source->mutable_metadata(), cpu_,
         data_source->parsing_config(), data_source->mutable_parse_errors(),
-        &last_read_event_ts_, parsing_buf, pages_read, compact_sched_buf,
-        table_, symbolizer_, ftrace_clock_snapshot_, ftrace_clock_);
+        &last_read_ts, parsing_buf, pages_read, compact_sched_buf, table_,
+        symbolizer_, ftrace_clock_snapshot_, ftrace_clock_);
   }
+  last_read_event_ts_ = last_read_ts;
 
   return pages_read;
 }
@@ -407,6 +410,13 @@
 // event bundle proto with a timestamp, letting the trace processor decide
 // whether to discard or keep the post-error data. Previously, we crashed as
 // soon as we encountered such an error.
+// TODO(rsavitski, b/192586066): consider moving last_read_event_ts tracking to
+// be per-datasource. The current implementation can be pessimistic if there are
+// multiple concurrent data sources, one of which is only interested in sparse
+// events (imagine a print filter and one matching event every minute, while the
+// buffers are read - advancing the last read timestamp - multiple times per
+// second). Tracking the timestamp of the last event *written into the
+// datasource* can be more accurate.
 // static
 bool CpuReader::ProcessPagesForDataSource(
     TraceWriter* trace_writer,
diff --git a/src/traced/probes/ftrace/cpu_reader_unittest.cc b/src/traced/probes/ftrace/cpu_reader_unittest.cc
index 33a8ca3..02d2962 100644
--- a/src/traced/probes/ftrace/cpu_reader_unittest.cc
+++ b/src/traced/probes/ftrace/cpu_reader_unittest.cc
@@ -1021,6 +1021,7 @@
   EXPECT_EQ(last_read_event_ts_, 1'045'157'726'697'236ULL);
 
   auto bundle = GetBundle();
+  EXPECT_EQ(0u, bundle.last_read_event_timestamp());
   ASSERT_EQ(bundle.event().size(), 6u);
   {
     const protos::gen::FtraceEvent& event = bundle.event()[1];
@@ -1078,6 +1079,7 @@
   auto bundle = GetBundle();
 
   const auto& compact_sched = bundle.compact_sched();
+  EXPECT_EQ(0u, bundle.last_read_event_timestamp());
 
   EXPECT_EQ(6u, compact_sched.switch_timestamp().size());
   EXPECT_EQ(6u, compact_sched.switch_prev_state().size());
@@ -3625,6 +3627,7 @@
 
   const uint64_t kSecondPrintTs = 1308020252356549ULL;
   EXPECT_EQ(kSecondPrintTs, first_bundle.event()[1].timestamp());
+  EXPECT_EQ(0u, first_bundle.last_read_event_timestamp());
 
   // 1 print + lost_events + updated last_read_event_timestamp
   auto const& second_bundle = packets[1].ftrace_events();
diff --git a/test/cts/heapprofd_test_cts.cc b/test/cts/heapprofd_test_cts.cc
index 6c87cf4..fec7aea 100644
--- a/test/cts/heapprofd_test_cts.cc
+++ b/test/cts/heapprofd_test_cts.cc
@@ -20,11 +20,14 @@
 #include <sys/wait.h>
 
 #include <random>
+#include <string>
+#include <string_view>
 
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/string_utils.h"
 #include "perfetto/tracing/core/data_source_config.h"
 #include "src/base/test/test_task_runner.h"
+#include "src/base/test/tmp_dir_tree.h"
 #include "test/android_test_utils.h"
 #include "test/gtest_and_gmock.h"
 #include "test/test_helper.h"
@@ -71,25 +74,47 @@
   return result;
 }
 
-std::optional<int64_t> ReadInt64FromFile(const std::string& path) {
-  std::string contents;
-  if (!base::ReadFile(path, &contents)) {
-    return std::nullopt;
+// Asks FileContentProvider.java inside the app to read a file.
+class ContentProviderReader {
+ public:
+  explicit ContentProviderReader(const std::string& app,
+                                 const std::string& path) {
+    tmp_dir_.TrackFile("contents.txt");
+    tempfile_ = tmp_dir_.AbsolutePath("contents.txt");
+    cmd_ = std::string("content read --uri content://") + app +
+           std::string("/") + path + " >" + tempfile_;
   }
-  return base::StringToInt64(contents);
-}
+  std::optional<int64_t> ReadInt64() {
+    if (system(cmd_.c_str()) != 0) {
+      return std::nullopt;
+    }
+    return ReadInt64FromFile(tempfile_);
+  }
+
+ private:
+  std::optional<int64_t> ReadInt64FromFile(const std::string& path) {
+    std::string contents;
+    if (!base::ReadFile(path, &contents)) {
+      return std::nullopt;
+    }
+    return base::StringToInt64(contents);
+  }
+
+  base::TmpDirTree tmp_dir_;
+  std::string tempfile_;
+  std::string cmd_;
+};
 
 bool WaitForAppAllocationCycle(const std::string& app_name, size_t timeout_ms) {
   const size_t sleep_per_attempt_us = 100 * 1000;
   const size_t max_attempts = timeout_ms * 1000 / sleep_per_attempt_us;
 
-  std::string path = std::string("/sdcard/Android/data/") + app_name +
-                     std::string("/files/") + std::string(kReportCyclePath);
+  ContentProviderReader app_reader(app_name, std::string(kReportCyclePath));
 
   for (size_t attempts = 0; attempts < max_attempts;) {
     int64_t first_value;
     for (; attempts < max_attempts; attempts++) {
-      std::optional<int64_t> val = ReadInt64FromFile(path);
+      std::optional<int64_t> val = app_reader.ReadInt64();
       if (val) {
         first_value = *val;
         break;
@@ -98,7 +123,7 @@
     }
 
     for (; attempts < max_attempts; attempts++) {
-      std::optional<int64_t> val = ReadInt64FromFile(path);
+      std::optional<int64_t> val = app_reader.ReadInt64();
       if (!val || *val < first_value) {
         break;
       }
diff --git a/test/cts/test_apps/AndroidManifest_debuggable.xml b/test/cts/test_apps/AndroidManifest_debuggable.xml
index 291469e..79993f8 100755
--- a/test/cts/test_apps/AndroidManifest_debuggable.xml
+++ b/test/cts/test_apps/AndroidManifest_debuggable.xml
@@ -71,5 +71,10 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity-alias>
+        <provider
+          android:name="android.perfetto.cts.app.FileContentProvider"
+          android:authorities="android.perfetto.cts.app.debuggable"
+          android:exported="true"
+          android:grantUriPermissions="true" />
     </application>
 </manifest>
diff --git a/test/cts/test_apps/AndroidManifest_nonprofileable.xml b/test/cts/test_apps/AndroidManifest_nonprofileable.xml
index a332175..8322daf 100755
--- a/test/cts/test_apps/AndroidManifest_nonprofileable.xml
+++ b/test/cts/test_apps/AndroidManifest_nonprofileable.xml
@@ -59,6 +59,11 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity-alias>
+        <provider
+          android:name="android.perfetto.cts.app.FileContentProvider"
+          android:authorities="android.perfetto.cts.app.nonprofileable"
+          android:exported="true"
+          android:grantUriPermissions="true" />
     </application>
 </manifest>
 
diff --git a/test/cts/test_apps/AndroidManifest_profileable.xml b/test/cts/test_apps/AndroidManifest_profileable.xml
index 077fd95..cd434d4 100755
--- a/test/cts/test_apps/AndroidManifest_profileable.xml
+++ b/test/cts/test_apps/AndroidManifest_profileable.xml
@@ -72,6 +72,11 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity-alias>
+        <provider
+          android:name="android.perfetto.cts.app.FileContentProvider"
+          android:authorities="android.perfetto.cts.app.profileable"
+          android:exported="true"
+          android:grantUriPermissions="true" />
     </application>
 </manifest>
 
diff --git a/test/cts/test_apps/AndroidManifest_release.xml b/test/cts/test_apps/AndroidManifest_release.xml
index 417a539..1795a59 100755
--- a/test/cts/test_apps/AndroidManifest_release.xml
+++ b/test/cts/test_apps/AndroidManifest_release.xml
@@ -71,5 +71,10 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity-alias>
+        <provider
+          android:name="android.perfetto.cts.app.FileContentProvider"
+          android:authorities="android.perfetto.cts.app.release"
+          android:exported="true"
+          android:grantUriPermissions="true" />
     </application>
 </manifest>
diff --git a/test/cts/test_apps/src/android/perfetto/cts/app/FileContentProvider.java b/test/cts/test_apps/src/android/perfetto/cts/app/FileContentProvider.java
new file mode 100644
index 0000000..5419cfe
--- /dev/null
+++ b/test/cts/test_apps/src/android/perfetto/cts/app/FileContentProvider.java
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+package android.perfetto.cts.app;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+
+public class FileContentProvider extends ContentProvider {
+    @Override
+    public boolean onCreate() {
+        return true;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        return null;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        return null;
+    }
+
+    @Override
+    public String[] getStreamTypes(Uri uri, String mimeTypeFilter) {
+        return null;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        return null;
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        return 0;
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        return 0;
+    }
+
+    @Override
+    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+        String filePath = uri.getPath();
+        File file = new File(getContext().getExternalFilesDir(null), filePath);
+        return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode));
+    }
+}
diff --git a/tools/gen_android_bp b/tools/gen_android_bp
index ba5320c..c8db4ec 100755
--- a/tools/gen_android_bp
+++ b/tools/gen_android_bp
@@ -71,6 +71,7 @@
     '//src/traced/probes:traced_probes',
     '//src/traced/service:traced',
     '//src/trace_processor:trace_processor_shell',
+    '//src/trace_redaction:trace_redactor',
     '//test/cts:perfetto_cts_deps',
     '//test/cts:perfetto_cts_jni_deps',
     '//test:perfetto_gtest_logcat_printer',
diff --git a/tools/tmux b/tools/tmux
index 48c625e..06594b8 100755
--- a/tools/tmux
+++ b/tools/tmux
@@ -56,18 +56,16 @@
 
 function reset_tracing() {
   if is_android "$OUT"; then
-    # Newer versions of Android don't have debugfs mounted at all
-    # anymore so use /sys/kernel/tracing if /d/tracing doesn't exist
-    adb shell 'test -d /sys/kernel/tracing && echo 0 > /sys/kernel/tracing/tracing_on || echo 0 > /sys/kernel/debug/tracing/tracing_on'
+    adb shell 'test -d /sys/kernel/tracing && echo 0 > /sys/kernel/tracing/tracing_on'
   elif ! is_mac; then
     # shellcheck disable=SC2016
     local script='
-    if [ ! -w /sys/kernel/debug ]; then
-      echo "debugfs not accessible, try sudo chown -R $USER /sys/kernel/debug"
-      sudo chown -R "$USER" /sys/kernel/debug
+    if [ ! -w /sys/kernel/tracing ]; then
+      echo "tracefs not accessible, try sudo chown -R $USER /sys/kernel/tracing"
+      sudo chown -R "$USER" /sys/kernel/tracing
     fi
 
-    echo 0 > /sys/kernel/debug/tracing/tracing_on
+    echo 0 > /sys/kernel/tracing/tracing_on
     '
 
     if is_ssh_target; then
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index a4f0213..54151fb 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -40,7 +40,6 @@
 import {defaultViewingOption} from './flamegraph_util';
 import {
   MetatraceTrackId,
-  traceEvent,
   traceEventBegin,
   traceEventEnd,
   TraceEventScope,
@@ -1077,22 +1076,6 @@
     state.hoveredNoteTimestamp = args.ts;
   },
 
-  // Tab V1 specific
-  setCurrentTab(state: StateDraft, args: {tab: string | undefined}) {
-    traceEvent(
-      'setCurrentTab',
-      () => {
-        state.currentTab = args.tab;
-      },
-      {
-        args: {
-          tab: args.tab ?? '<undefined>',
-        },
-      },
-    );
-  },
-
-  // Specific to tabs V2.
   // Add a tab with a given URI to the tab bar and show it.
   // If the tab is already present in the tab bar, just show it.
   showTab(state: StateDraft, args: {uri: string}) {
@@ -1106,7 +1089,6 @@
     state.tabs.currentTab = args.uri;
   },
 
-  // Specific to tabs V2.
   // Hide a tab in the tab bar pick a new tab to show.
   // Note: Attempting to hide the "current_selection" tab doesn't work. This tab
   // is special and cannot be removed.
diff --git a/ui/src/common/selection_observer.ts b/ui/src/common/selection_observer.ts
deleted file mode 100644
index 8cb7f12..0000000
--- a/ui/src/common/selection_observer.ts
+++ /dev/null
@@ -1,37 +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 {LegacySelection} from './state';
-
-export type SelectionChangedObserver = (
-  selection: LegacySelection | undefined,
-  openCurrentSelectionTab: boolean,
-) => void;
-
-const selectionObservers: SelectionChangedObserver[] = [];
-
-export function onSelectionChanged(
-  selection: LegacySelection | undefined,
-  openCurrentSelectionTab: boolean,
-) {
-  for (const observer of selectionObservers) {
-    observer(selection, openCurrentSelectionTab);
-  }
-}
-
-export function addSelectionChangeObserver(
-  observer: SelectionChangedObserver,
-): void {
-  selectionObservers.push(observer);
-}
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 11fe179..9aa747b 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -15,7 +15,6 @@
 import {BigintMath} from '../base/bigint_math';
 import {duration, Time, time} from '../base/time';
 import {RecordConfig} from '../controller/record_config_types';
-import {GenericSliceDetailsTabConfigBase} from '../frontend/generic_slice_details_tab';
 import {
   Aggregation,
   PivotTree,
@@ -25,6 +24,28 @@
 
 import {Direction} from './event_set';
 
+import {
+  selectionToLegacySelection,
+  Selection,
+  LegacySelection,
+  ProfileType,
+} from '../core/selection_manager';
+
+export {
+  Selection,
+  SelectionKind,
+  NoteSelection,
+  SliceSelection,
+  CounterSelection,
+  HeapProfileSelection,
+  PerfSamplesSelection,
+  LegacySelection,
+  AreaSelection,
+  ProfileType,
+  ChromeSliceSelection,
+  CpuProfileSampleSelection,
+} from '../core/selection_manager';
+
 /**
  * A plain js object, holding objects of type |Class| keyed by string id.
  * We use this instead of using |Map| object since it is simpler and faster to
@@ -60,18 +81,6 @@
   resolution: duration;
 }
 
-export interface AreaSelection {
-  kind: 'AREA';
-  areaId: string;
-  // When an area is marked it will be assigned a unique note id and saved as
-  // an AreaNote for the user to return to later. id = 0 is the special id that
-  // is overwritten when a new area is marked. Any other id is a persistent
-  // marking that will not be overwritten.
-  // When not set, the area selection will be replaced with any
-  // new area selection (i.e. not saved anywhere).
-  noteId?: string;
-}
-
 export type AreaById = Area & {id: string};
 
 export interface Area {
@@ -136,7 +145,8 @@
 // 46. Remove trackKeyByTrackId.
 // 47. Selection V2
 // 48. Rename legacySelection -> selection and introduce new Selection type.
-export const STATE_VERSION = 48;
+// 49. Remove currentTab, which is only relevant to TabsV1.
+export const STATE_VERSION = 49;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
 
@@ -172,15 +182,6 @@
   };
 };
 
-export enum ProfileType {
-  HEAP_PROFILE = 'heap_profile',
-  MIXED_HEAP_PROFILE = 'heap_profile:com.android.art,libc.malloc',
-  NATIVE_HEAP_PROFILE = 'heap_profile:libc.malloc',
-  JAVA_HEAP_SAMPLES = 'heap_profile:com.android.art',
-  JAVA_HEAP_GRAPH = 'graph',
-  PERF_SAMPLE = 'perf',
-}
-
 export enum FlamegraphStateViewingOption {
   SPACE_MEMORY_ALLOCATED_NOT_FREED_KEY = 'SPACE',
   ALLOC_SPACE_MEMORY_ALLOCATED_KEY = 'ALLOC_SPACE',
@@ -189,6 +190,17 @@
   PERF_SAMPLES_KEY = 'PERF_SAMPLES',
 }
 
+export interface FlamegraphState {
+  kind: 'FLAMEGRAPH_STATE';
+  upids: number[];
+  start: time;
+  end: time;
+  type: ProfileType;
+  viewingOption: FlamegraphStateViewingOption;
+  focusRegex: string;
+  expandedCallsite?: CallsiteInfo;
+}
+
 export interface CallsiteInfo {
   id: number;
   parentId: number;
@@ -309,134 +321,6 @@
   text: string;
 }
 
-export interface NoteSelection {
-  kind: 'NOTE';
-  id: string;
-}
-
-export interface SliceSelection {
-  kind: 'SLICE';
-  id: number;
-}
-
-export interface CounterSelection {
-  kind: 'COUNTER';
-  leftTs: time;
-  rightTs: time;
-  id: number;
-}
-
-export interface HeapProfileSelection {
-  kind: 'HEAP_PROFILE';
-  id: number;
-  upid: number;
-  ts: time;
-  type: ProfileType;
-}
-
-export interface PerfSamplesSelection {
-  kind: 'PERF_SAMPLES';
-  id: number;
-  upid: number;
-  leftTs: time;
-  rightTs: time;
-  type: ProfileType;
-}
-
-export interface FlamegraphState {
-  kind: 'FLAMEGRAPH_STATE';
-  upids: number[];
-  start: time;
-  end: time;
-  type: ProfileType;
-  viewingOption: FlamegraphStateViewingOption;
-  focusRegex: string;
-  expandedCallsite?: CallsiteInfo;
-}
-
-export interface CpuProfileSampleSelection {
-  kind: 'CPU_PROFILE_SAMPLE';
-  id: number;
-  utid: number;
-  ts: time;
-}
-
-export interface ChromeSliceSelection {
-  kind: 'CHROME_SLICE';
-  id: number;
-  table?: string;
-}
-
-export interface ThreadStateSelection {
-  kind: 'THREAD_STATE';
-  id: number;
-}
-
-export interface LogSelection {
-  kind: 'LOG';
-  id: number;
-  trackKey: string;
-}
-
-export interface GenericSliceSelection {
-  kind: 'GENERIC_SLICE';
-  id: number;
-  sqlTableName: string;
-  start: time;
-  duration: duration;
-  // NOTE: this config can be expanded for multiple details panel types.
-  detailsPanelConfig: {kind: string; config: GenericSliceDetailsTabConfigBase};
-}
-
-export type LegacySelection = (
-  | NoteSelection
-  | SliceSelection
-  | CounterSelection
-  | HeapProfileSelection
-  | CpuProfileSampleSelection
-  | ChromeSliceSelection
-  | ThreadStateSelection
-  | AreaSelection
-  | PerfSamplesSelection
-  | LogSelection
-  | GenericSliceSelection
-) & {trackKey?: string};
-export type SelectionKind = LegacySelection['kind']; // 'THREAD_STATE' | 'SLICE' ...
-
-export interface LegacySelectionWrapper {
-  kind: 'legacy';
-  legacySelection: LegacySelection;
-}
-
-export interface SingleSelection {
-  kind: 'single';
-  trackKey: string;
-  eventId: string;
-}
-
-export interface NewAreaSelection {
-  kind: 'area';
-  trackKey: string;
-  start: time;
-  end: time;
-}
-
-export interface UnionSelection {
-  kind: 'union';
-  selections: Selection[];
-}
-
-export interface EmptySelection {
-  kind: 'empty';
-}
-
-export type Selection =
-  | SingleSelection
-  | NewAreaSelection
-  | UnionSelection
-  | EmptySelection
-  | LegacySelectionWrapper;
-
 export interface Pagination {
   offset: number;
   count: number;
@@ -642,8 +526,6 @@
 
   searchIndex: number;
 
-  currentTab?: string;
-
   tabs: TabsV2State;
 
   /**
@@ -1035,9 +917,5 @@
 }
 
 export function getLegacySelection(state: State): LegacySelection | null {
-  const selection = state.selection;
-  if (selection.kind === 'legacy') {
-    return selection.legacySelection;
-  }
-  return null;
+  return selectionToLegacySelection(state.selection);
 }
diff --git a/ui/src/controller/pivot_table_controller.ts b/ui/src/controller/pivot_table_controller.ts
index b85f827..b1eb386 100644
--- a/ui/src/controller/pivot_table_controller.ts
+++ b/ui/src/controller/pivot_table_controller.ts
@@ -270,7 +270,6 @@
         queryResult: {tree: treeBuilder.build(), metadata: query.metadata},
       }),
     );
-    globals.dispatch(Actions.setCurrentTab({tab: 'pivot_table'}));
   }
 
   async requestArgumentNames() {
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 5bdc934..72d1498 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -26,16 +26,13 @@
   isMetatracingEnabled,
 } from '../common/metatracing';
 import {pluginManager} from '../common/plugins';
-import {onSelectionChanged} from '../common/selection_observer';
 import {
   defaultTraceTime,
   EngineMode,
   PendingDeeplinkState,
   ProfileType,
-  getLegacySelection,
 } from '../common/state';
 import {featureFlags, Flag, PERF_SAMPLE_FLAG} from '../core/feature_flags';
-import {BottomTabList} from '../frontend/bottom_tab';
 import {
   FtraceStat,
   globals,
@@ -421,7 +418,6 @@
         assertExists(getEnabledMetatracingCategories()),
       );
     }
-    globals.bottomTabList = new BottomTabList(engine.getProxy('BottomTabList'));
 
     globals.engines.set(this.engineId, engine);
     globals.dispatch(
@@ -673,14 +669,6 @@
       }
     }
 
-    // If the trace was shared via a permalink, it might already have a
-    // selection. Emit onSelectionChanged to ensure that the components (like
-    // current selection details) react to it.
-    const currentSelection = getLegacySelection(globals.state);
-    if (currentSelection !== null) {
-      onSelectionChanged(currentSelection, true);
-    }
-
     globals.dispatch(Actions.maybeExpandOnlyTrackGroup({}));
 
     // Trace Processor doesn't support the reliable range feature for JSON
diff --git a/ui/src/controller/track_decider.ts b/ui/src/controller/track_decider.ts
index 206d1a9..7d1039b 100644
--- a/ui/src/controller/track_decider.ts
+++ b/ui/src/controller/track_decider.ts
@@ -450,7 +450,7 @@
       if (!devMap.has(groupName)) {
         devMap.set(groupName, uuidv4());
       }
-      track.name = 'Size: ' + size;
+      track.name = 'Chunk size: ' + size;
       track.trackGroup = devMap.get(groupName);
     }
 
diff --git a/ui/src/core/feature_flags.ts b/ui/src/core/feature_flags.ts
index 647b546..e0253cb 100644
--- a/ui/src/core/feature_flags.ts
+++ b/ui/src/core/feature_flags.ts
@@ -247,10 +247,3 @@
   description: 'Record using V2 interface',
   defaultValue: false,
 });
-
-export const TABS_V2_FLAG = featureFlags.register({
-  id: 'tabsv2',
-  name: 'Tabs V2',
-  description: 'Use Tabs V2',
-  defaultValue: true,
-});
diff --git a/ui/src/core/generic_slice_details_types.ts b/ui/src/core/generic_slice_details_types.ts
new file mode 100644
index 0000000..186f6e5
--- /dev/null
+++ b/ui/src/core/generic_slice_details_types.ts
@@ -0,0 +1,32 @@
+// 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.
+
+export interface ColumnConfig {
+  displayName?: string;
+}
+
+export type Columns = {
+  [columnName: string]: ColumnConfig;
+};
+
+export interface GenericSliceDetailsTabConfigBase {
+  sqlTableName: string;
+  title: string;
+  // All columns are rendered if |columns| is undefined.
+  columns?: Columns;
+}
+
+export type GenericSliceDetailsTabConfig = GenericSliceDetailsTabConfigBase & {
+  id: number;
+};
diff --git a/ui/src/core/selection_manager.ts b/ui/src/core/selection_manager.ts
index 0a5b9be..961ce95 100644
--- a/ui/src/core/selection_manager.ts
+++ b/ui/src/core/selection_manager.ts
@@ -11,3 +11,155 @@
 // 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 {duration, time} from '../base/time';
+import {GenericSliceDetailsTabConfigBase} from './generic_slice_details_types';
+
+export enum ProfileType {
+  HEAP_PROFILE = 'heap_profile',
+  MIXED_HEAP_PROFILE = 'heap_profile:com.android.art,libc.malloc',
+  NATIVE_HEAP_PROFILE = 'heap_profile:libc.malloc',
+  JAVA_HEAP_SAMPLES = 'heap_profile:com.android.art',
+  JAVA_HEAP_GRAPH = 'graph',
+  PERF_SAMPLE = 'perf',
+}
+
+// LEGACY Selection types:
+export interface AreaSelection {
+  kind: 'AREA';
+  areaId: string;
+  // When an area is marked it will be assigned a unique note id and saved as
+  // an AreaNote for the user to return to later. id = 0 is the special id that
+  // is overwritten when a new area is marked. Any other id is a persistent
+  // marking that will not be overwritten.
+  // When not set, the area selection will be replaced with any
+  // new area selection (i.e. not saved anywhere).
+  noteId?: string;
+}
+
+export interface NoteSelection {
+  kind: 'NOTE';
+  id: string;
+}
+
+export interface SliceSelection {
+  kind: 'SLICE';
+  id: number;
+}
+
+export interface CounterSelection {
+  kind: 'COUNTER';
+  leftTs: time;
+  rightTs: time;
+  id: number;
+}
+
+export interface HeapProfileSelection {
+  kind: 'HEAP_PROFILE';
+  id: number;
+  upid: number;
+  ts: time;
+  type: ProfileType;
+}
+
+export interface PerfSamplesSelection {
+  kind: 'PERF_SAMPLES';
+  id: number;
+  upid: number;
+  leftTs: time;
+  rightTs: time;
+  type: ProfileType;
+}
+
+export interface CpuProfileSampleSelection {
+  kind: 'CPU_PROFILE_SAMPLE';
+  id: number;
+  utid: number;
+  ts: time;
+}
+
+export interface ChromeSliceSelection {
+  kind: 'CHROME_SLICE';
+  id: number;
+  table?: string;
+}
+
+export interface ThreadStateSelection {
+  kind: 'THREAD_STATE';
+  id: number;
+}
+
+export interface LogSelection {
+  kind: 'LOG';
+  id: number;
+  trackKey: string;
+}
+
+export interface GenericSliceSelection {
+  kind: 'GENERIC_SLICE';
+  id: number;
+  sqlTableName: string;
+  start: time;
+  duration: duration;
+  // NOTE: this config can be expanded for multiple details panel types.
+  detailsPanelConfig: {kind: string; config: GenericSliceDetailsTabConfigBase};
+}
+
+export type LegacySelection = (
+  | NoteSelection
+  | SliceSelection
+  | CounterSelection
+  | HeapProfileSelection
+  | CpuProfileSampleSelection
+  | ChromeSliceSelection
+  | ThreadStateSelection
+  | AreaSelection
+  | PerfSamplesSelection
+  | LogSelection
+  | GenericSliceSelection
+) & {trackKey?: string};
+export type SelectionKind = LegacySelection['kind']; // 'THREAD_STATE' | 'SLICE' ...
+
+// New Selection types:
+export interface LegacySelectionWrapper {
+  kind: 'legacy';
+  legacySelection: LegacySelection;
+}
+
+export interface SingleSelection {
+  kind: 'single';
+  trackKey: string;
+  eventId: string;
+}
+
+export interface NewAreaSelection {
+  kind: 'area';
+  trackKey: string;
+  start: time;
+  end: time;
+}
+
+export interface UnionSelection {
+  kind: 'union';
+  selections: Selection[];
+}
+
+export interface EmptySelection {
+  kind: 'empty';
+}
+
+export type Selection =
+  | SingleSelection
+  | NewAreaSelection
+  | UnionSelection
+  | EmptySelection
+  | LegacySelectionWrapper;
+
+export function selectionToLegacySelection(
+  selection: Selection,
+): LegacySelection | null {
+  if (selection.kind === 'legacy') {
+    return selection.legacySelection;
+  }
+  return null;
+}
diff --git a/ui/src/frontend/aggregation_tab.ts b/ui/src/frontend/aggregation_tab.ts
index f9adf10..c6c46f6 100644
--- a/ui/src/frontend/aggregation_tab.ts
+++ b/ui/src/frontend/aggregation_tab.ts
@@ -23,6 +23,7 @@
 import {EmptyState} from '../widgets/empty_state';
 import {FlowEventsAreaSelectedPanel} from './flow_events_panel';
 import {PivotTable} from './pivot_table';
+import {FlamegraphDetailsPanel} from './flamegraph_panel';
 
 interface View {
   key: string;
@@ -54,6 +55,14 @@
   private getViews(): View[] {
     const views = [];
 
+    if (globals.flamegraphDetails.isInAreaSelection) {
+      views.push({
+        key: 'flamegraph_selection',
+        name: 'Flamegraph Selection',
+        content: m(FlamegraphDetailsPanel, {key: 'flamegraph'}),
+      });
+    }
+
     for (const [key, value] of globals.aggregateDataStore.entries()) {
       if (!isEmptyData(value)) {
         views.push({
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index bad01f0..17b0ad9 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -589,25 +589,25 @@
       id: 'perfetto.NextFlow',
       name: 'Next flow',
       callback: () => focusOtherFlow('Forward'),
-      defaultHotkey: ']',
+      defaultHotkey: 'Mod+]',
     },
     {
       id: 'perfetto.PrevFlow',
       name: 'Prev flow',
       callback: () => focusOtherFlow('Backward'),
-      defaultHotkey: '[',
+      defaultHotkey: 'Mod+[',
     },
     {
       id: 'perfetto.MoveNextFlow',
       name: 'Move next flow',
       callback: () => moveByFocusedFlow('Forward'),
-      defaultHotkey: 'Mod+]',
+      defaultHotkey: ']',
     },
     {
       id: 'perfetto.MovePrevFlow',
       name: 'Move prev flow',
       callback: () => moveByFocusedFlow('Backward'),
-      defaultHotkey: 'Mod+[',
+      defaultHotkey: '[',
     },
     {
       id: 'perfetto.SelectAll',
diff --git a/ui/src/frontend/bottom_tab.ts b/ui/src/frontend/bottom_tab.ts
index 4b82001..d8ed117 100644
--- a/ui/src/frontend/bottom_tab.ts
+++ b/ui/src/frontend/bottom_tab.ts
@@ -13,18 +13,9 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {v4 as uuidv4} from 'uuid';
 
-import {stringifyJsonWithBigints} from '../base/json_utils';
-import {exists} from '../base/utils';
-import {Actions} from '../common/actions';
-import {traceEvent} from '../common/metatracing';
-import {Registry} from '../base/registry';
-import {raf} from '../core/raf_scheduler';
 import {EngineProxy} from '../trace_processor/engine';
 
-import {globals} from './globals';
-
 export interface NewBottomTabArgs<Config> {
   engine: EngineProxy;
   tag?: string;
@@ -32,22 +23,6 @@
   config: Config;
 }
 
-// Interface for allowing registration and creation of bottom tabs.
-// See comments on |TrackCreator| for more details.
-export interface BottomTabCreator {
-  readonly kind: string;
-
-  create(args: NewBottomTabArgs<unknown>): BottomTab;
-}
-
-export const bottomTabRegistry = Registry.kindRegistry<BottomTabCreator>();
-
-// Period to wait for the newly-added tabs which are loading before showing
-// them to the user. This period is short enough to not be user-visible,
-// while being long enough for most of the simple queries to complete, reducing
-// flickering in the UI.
-const NEW_LOADING_TAB_DELAY_MS = 50;
-
 // An interface representing a bottom tab displayed on the panel in the bottom
 // of the ui (e.g. "Current Selection").
 //
@@ -115,10 +90,6 @@
 
   abstract viewTab(): m.Children;
 
-  close(): void {
-    closeTab(this.uuid);
-  }
-
   renderPanel(): m.Children {
     return m(BottomTabAdapter, {
       key: this.uuid,
@@ -136,222 +107,3 @@
     return vnode.attrs.panel.viewTab();
   }
 }
-
-export type AddTabArgs = {
-  kind: string;
-  config: {};
-  tag?: string;
-  // Whether to make the new tab current. True by default.
-  select?: boolean;
-};
-
-export type AddTabResult = {
-  uuid: string;
-};
-
-// Shorthand for globals.bottomTabList.addTab(...) & redraw.
-// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI).
-export function addTab(args: AddTabArgs) {
-  const tabList = globals.bottomTabList;
-  if (!tabList) {
-    return;
-  }
-  tabList.addTab(args);
-  raf.scheduleFullRedraw();
-}
-
-// Shorthand for globals.bottomTabList.closeTabById(...) & redraw.
-// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI).
-export function closeTab(uuid: string) {
-  const tabList = globals.bottomTabList;
-  if (!tabList) {
-    return;
-  }
-  tabList.closeTabById(uuid);
-  raf.scheduleFullRedraw();
-}
-
-interface PendingTab {
-  tab: BottomTabBase;
-  args: AddTabArgs;
-  startTime: number;
-}
-
-function tabSelectionKey(tab: BottomTabBase) {
-  return tab.tag ?? tab.uuid;
-}
-
-export class BottomTabList {
-  private tabs: BottomTabBase[] = [];
-  private pendingTabs: PendingTab[] = [];
-  private engine: EngineProxy;
-  private scheduledFlushSetTimeoutId?: number;
-
-  constructor(engine: EngineProxy) {
-    this.engine = engine;
-  }
-
-  getTabs(): BottomTabBase[] {
-    this.flushPendingTabs();
-    return this.tabs;
-  }
-
-  // Add and create a new panel with given kind and config, replacing an
-  // existing panel with the same tag if needed. Returns the uuid of a newly
-  // created panel (which can be used in the future to close it).
-  addTab(args: AddTabArgs): AddTabResult {
-    const uuid = uuidv4();
-    return traceEvent(
-      'addTab',
-      () => {
-        const newPanel = bottomTabRegistry.get(args.kind).create({
-          engine: this.engine,
-          uuid,
-          config: args.config,
-          tag: args.tag,
-        });
-
-        this.pendingTabs.push({
-          tab: newPanel,
-          args,
-          startTime: window.performance.now(),
-        });
-        this.flushPendingTabs();
-
-        return {
-          uuid,
-        };
-      },
-      {
-        args: {
-          uuid: uuid,
-          kind: args.kind,
-          tag: args.tag ?? '<undefined>',
-          config: stringifyJsonWithBigints(args.config),
-        },
-      },
-    );
-  }
-
-  closeTabByTag(tag: string) {
-    const index = this.tabs.findIndex((tab) => tab.tag === tag);
-    if (index !== -1) {
-      this.removeTabAtIndex(index);
-    }
-    // User closing a tab by tag should affect pending tabs as well, as these
-    // tabs were requested to be added to the tab list before this call.
-    this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.tag !== tag);
-  }
-
-  closeTabById(uuid: string) {
-    const index = this.tabs.findIndex((tab) => tab.uuid === uuid);
-    if (index !== -1) {
-      this.removeTabAtIndex(index);
-    }
-    // User closing a tab by id should affect pending tabs as well, as these
-    // tabs were requested to be added to the tab list before this call.
-    this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.uuid !== uuid);
-  }
-
-  private removeTabAtIndex(index: number) {
-    const tab = this.tabs[index];
-    this.tabs.splice(index, 1);
-    // If the current tab was closed, select the tab to the right of it.
-    // If the closed tab was current and last in the tab list, select the tab
-    // that became last.
-    if (tab.uuid === globals.state.currentTab && this.tabs.length > 0) {
-      const newActiveIndex = index === this.tabs.length ? index - 1 : index;
-      globals.dispatch(
-        Actions.setCurrentTab({
-          tab: tabSelectionKey(this.tabs[newActiveIndex]),
-        }),
-      );
-    }
-    raf.scheduleFullRedraw();
-  }
-
-  // Check the list of the pending tabs and add the ones that are ready
-  // (either tab.isLoading returns false or NEW_LOADING_TAB_DELAY_MS ms elapsed
-  // since this tab was added).
-  // Note: the pending tabs are stored in a queue to preserve the action order,
-  // which matters for cases like adding tabs with the same tag.
-  private flushPendingTabs() {
-    const currentTime = window.performance.now();
-    while (this.pendingTabs.length > 0) {
-      const {tab, args, startTime} = this.pendingTabs[0];
-
-      // This is a dirty hack^W^W low-lift solution for the world where some
-      // "current selection" panels are implemented by BottomTabs and some by
-      // details_panel.ts computing vnodes dynamically. Naive implementation
-      // will: a) stop showing the old panel (because
-      // globals.state.currentSelection changes). b) not showing the new
-      // 'current_selection' tab yet. This will result in temporary shifting
-      // focus to another tab (as no tab with 'current_selection' tag will
-      // exist).
-      //
-      // To counteract this, short-circuit this logic and when:
-      // a) no tag with 'current_selection' tag exists in the list of currently
-      // displayed tabs and b) we are adding a tab with 'current_selection' tag.
-      // add it immediately without waiting.
-      // TODO(altimin): Remove this once all places have switched to be using
-      // BottomTab to display panels.
-      const currentSelectionTabAlreadyExists =
-        this.tabs.filter((tab) => tab.tag === 'current_selection').length > 0;
-      const dirtyHackForCurrentSelectionApplies =
-        tab.tag === 'current_selection' && !currentSelectionTabAlreadyExists;
-
-      const elapsedTimeMs = currentTime - startTime;
-      if (
-        tab.isLoading() &&
-        elapsedTimeMs < NEW_LOADING_TAB_DELAY_MS &&
-        !dirtyHackForCurrentSelectionApplies
-      ) {
-        this.schedulePendingTabsFlush(NEW_LOADING_TAB_DELAY_MS - elapsedTimeMs);
-        // The first tab is not ready yet, wait.
-        return;
-      }
-
-      traceEvent(
-        'addPendingTab',
-        () => {
-          this.pendingTabs.shift();
-
-          const index = args.tag
-            ? this.tabs.findIndex((tab) => tab.tag === args.tag)
-            : -1;
-          if (index === -1) {
-            this.tabs.push(tab);
-          } else {
-            this.tabs[index] = tab;
-          }
-
-          if (args.select === undefined || args.select === true) {
-            globals.dispatch(
-              Actions.setCurrentTab({tab: tabSelectionKey(tab)}),
-            );
-          }
-          // setCurrentTab will usually schedule a redraw, but not if we replace
-          // the tab with the same tag, so we force an update here.
-          raf.scheduleFullRedraw();
-        },
-        {
-          args: {
-            uuid: tab.uuid,
-            is_loading: tab.isLoading().toString(),
-          },
-        },
-      );
-    }
-  }
-
-  private schedulePendingTabsFlush(waitTimeMs: number) {
-    if (exists(this.scheduledFlushSetTimeoutId)) {
-      // The flush is already pending, no action is required.
-      return;
-    }
-    setTimeout(() => {
-      this.scheduledFlushSetTimeoutId = undefined;
-      this.flushPendingTabs();
-    }, waitTimeMs);
-  }
-}
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index 85d94b5..90471ed 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -28,7 +28,7 @@
 import {Section} from '../widgets/section';
 import {Tree, TreeNode} from '../widgets/tree';
 
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {BottomTab, NewBottomTabArgs} from './bottom_tab';
 import {FlowPoint, globals} from './globals';
 import {hasArgs, renderArguments} from './slice_args';
 import {renderDetails} from './slice_details';
@@ -416,5 +416,3 @@
     }
   }
 }
-
-bottomTabRegistry.register(ChromeSliceDetailsTab);
diff --git a/ui/src/frontend/details_panel.ts b/ui/src/frontend/details_panel.ts
deleted file mode 100644
index 8ca09aa..0000000
--- a/ui/src/frontend/details_panel.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-// Copyright (C) 2019 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 {Gate} from '../base/mithril_utils';
-import {exists} from '../base/utils';
-import {Actions} from '../common/actions';
-import {isEmptyData} from '../common/aggregation_data';
-import {LogExists, LogExistsKey} from '../common/logs';
-import {addSelectionChangeObserver} from '../common/selection_observer';
-import {LegacySelection, getLegacySelection} from '../common/state';
-
-import {AggregationPanel} from './aggregation_panel';
-import {ChromeSliceDetailsTab} from './chrome_slice_details_tab';
-import {CounterDetailsPanel} from './counter_panel';
-import {CpuProfileDetailsPanel} from './cpu_profile_panel';
-import {DragHandle, getDefaultDetailsHeight} from './drag_handle';
-import {FlamegraphDetailsPanel} from './flamegraph_panel';
-import {
-  FlowEventsAreaSelectedPanel,
-  FlowEventsPanel,
-} from './flow_events_panel';
-import {FtracePanel} from './ftrace_panel';
-import {globals} from './globals';
-import {LogPanel} from './logs_panel';
-import {NotesEditorTab} from './notes_panel';
-import {PivotTable} from './pivot_table';
-import {SliceDetailsPanel} from './slice_details_panel';
-import {ThreadStateTab} from './thread_state_tab';
-
-export const CURRENT_SELECTION_TAG = 'current_selection';
-
-function hasLogs(): boolean {
-  const data = globals.trackDataStore.get(LogExistsKey) as
-    | LogExists
-    | undefined;
-  return Boolean(data?.exists);
-}
-
-function handleSelectionChange(
-  newSelection: LegacySelection | undefined,
-  openCurrentSelectionTab: boolean,
-): void {
-  const currentSelectionTag = CURRENT_SELECTION_TAG;
-  const bottomTabList = globals.bottomTabList;
-  if (!bottomTabList) return;
-  if (newSelection === undefined) {
-    bottomTabList.closeTabByTag(currentSelectionTag);
-    return;
-  }
-  switch (newSelection.kind) {
-    case 'NOTE':
-      bottomTabList.addTab({
-        kind: NotesEditorTab.kind,
-        tag: currentSelectionTag,
-        config: {
-          id: newSelection.id,
-        },
-        select: openCurrentSelectionTab,
-      });
-      break;
-    case 'AREA':
-      if (newSelection.noteId !== undefined) {
-        bottomTabList.addTab({
-          kind: NotesEditorTab.kind,
-          tag: currentSelectionTag,
-          config: {
-            id: newSelection.noteId,
-          },
-          select: openCurrentSelectionTab,
-        });
-      }
-      break;
-    case 'THREAD_STATE':
-      bottomTabList.addTab({
-        kind: ThreadStateTab.kind,
-        tag: currentSelectionTag,
-        config: {
-          id: newSelection.id,
-        },
-        select: openCurrentSelectionTab,
-      });
-      break;
-    case 'GENERIC_SLICE':
-      bottomTabList.addTab({
-        kind: newSelection.detailsPanelConfig.kind,
-        tag: currentSelectionTag,
-        config: newSelection.detailsPanelConfig.config,
-        select: openCurrentSelectionTab,
-      });
-      break;
-    case 'CHROME_SLICE':
-      bottomTabList.addTab({
-        kind: ChromeSliceDetailsTab.kind,
-        tag: currentSelectionTag,
-        config: {
-          id: newSelection.id,
-          table: newSelection.table,
-        },
-        select: openCurrentSelectionTab,
-      });
-      break;
-    default:
-      bottomTabList.closeTabByTag(currentSelectionTag);
-  }
-}
-addSelectionChangeObserver(handleSelectionChange);
-
-export class DetailsPanel implements m.ClassComponent {
-  private detailsHeight = getDefaultDetailsHeight();
-
-  view() {
-    interface DetailsPanel {
-      key: string;
-      name: string;
-      vnode: m.Children;
-    }
-
-    const detailsPanels: DetailsPanel[] = [];
-
-    if (globals.bottomTabList) {
-      for (const tab of globals.bottomTabList.getTabs()) {
-        detailsPanels.push({
-          key: tab.tag ?? tab.uuid,
-          name: tab.getTitle(),
-          vnode: tab.renderPanel(),
-        });
-      }
-    }
-
-    const curSelection = getLegacySelection(globals.state);
-    if (curSelection) {
-      switch (curSelection.kind) {
-        case 'NOTE':
-          // Handled in handleSelectionChange.
-          break;
-        case 'AREA':
-          if (globals.flamegraphDetails.isInAreaSelection) {
-            detailsPanels.push({
-              key: 'flamegraph_selection',
-              name: 'Flamegraph Selection',
-              vnode: m(FlamegraphDetailsPanel, {key: 'flamegraph'}),
-            });
-          }
-          break;
-        case 'SLICE':
-          detailsPanels.push({
-            key: 'current_selection',
-            name: 'Current Selection',
-            vnode: m(SliceDetailsPanel, {
-              key: 'slice',
-            }),
-          });
-          break;
-        case 'COUNTER':
-          detailsPanels.push({
-            key: 'current_selection',
-            name: 'Current Selection',
-            vnode: m(CounterDetailsPanel, {
-              key: 'counter',
-            }),
-          });
-          break;
-        case 'PERF_SAMPLES':
-        case 'HEAP_PROFILE':
-          detailsPanels.push({
-            key: 'current_selection',
-            name: 'Current Selection',
-            vnode: m(FlamegraphDetailsPanel, {key: 'flamegraph'}),
-          });
-          break;
-        case 'CPU_PROFILE_SAMPLE':
-          detailsPanels.push({
-            key: 'current_selection',
-            name: 'Current Selection',
-            vnode: m(CpuProfileDetailsPanel, {
-              key: 'cpu_profile_sample',
-            }),
-          });
-          break;
-        default:
-          break;
-      }
-    }
-    if (hasLogs()) {
-      detailsPanels.push({
-        key: 'android_logs',
-        name: 'Android Logs',
-        vnode: m(LogPanel, {key: 'logs_panel'}),
-      });
-    }
-
-    const trackGroup = globals.state.trackGroups['ftrace-track-group'];
-    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-    if (trackGroup) {
-      const {collapsed} = trackGroup;
-      if (!collapsed) {
-        detailsPanels.push({
-          key: 'ftrace_events',
-          name: 'Ftrace Events',
-          vnode: m(FtracePanel, {key: 'ftrace_panel'}),
-        });
-      }
-    }
-
-    if (
-      globals.state.nonSerializableState.pivotTable.selectionArea !== undefined
-    ) {
-      detailsPanels.push({
-        key: 'pivot_table',
-        name: 'Pivot Table',
-        vnode: m(PivotTable, {
-          key: 'pivot_table',
-          selectionArea:
-            globals.state.nonSerializableState.pivotTable.selectionArea,
-        }),
-      });
-    }
-
-    if (globals.connectedFlows.length > 0) {
-      detailsPanels.push({
-        key: 'bound_flows',
-        name: 'Flow Events',
-        vnode: m(FlowEventsPanel, {key: 'flow_events'}),
-      });
-    }
-
-    for (const [key, value] of globals.aggregateDataStore.entries()) {
-      if (!isEmptyData(value)) {
-        detailsPanels.push({
-          key: value.tabName,
-          name: value.tabName,
-          vnode: m(AggregationPanel, {kind: key, key, data: value}),
-        });
-      }
-    }
-
-    // Add this after all aggregation panels, to make it appear after 'Slices'
-    if (globals.selectedFlows.length > 0) {
-      detailsPanels.push({
-        key: 'selected_flows',
-        name: 'Flow Events',
-        vnode: m(FlowEventsAreaSelectedPanel, {key: 'flow_events_area'}),
-      });
-    }
-
-    let currentTabDetails = detailsPanels.find(
-      (tab) => tab.key === globals.state.currentTab,
-    );
-    if (currentTabDetails === undefined && detailsPanels.length > 0) {
-      currentTabDetails = detailsPanels[0];
-    }
-
-    const panel = currentTabDetails?.vnode;
-
-    if (!exists(panel)) {
-      return null;
-    }
-
-    return [
-      m(DragHandle, {
-        resize: (height: number) => {
-          this.detailsHeight = Math.max(height, 0);
-        },
-        height: this.detailsHeight,
-        tabs: detailsPanels.map((tab) => {
-          return {key: tab.key, title: tab.name};
-        }),
-        currentTabKey: currentTabDetails?.key,
-        onTabClick: (key) => {
-          globals.dispatch(Actions.setCurrentTab({tab: key}));
-        },
-      }),
-      m(
-        '.details-panel-container',
-        {
-          style: {height: `${this.detailsHeight}px`},
-        },
-        detailsPanels.map((tab) => {
-          const active = tab === currentTabDetails;
-          return m(Gate, {open: active}, tab.vnode);
-        }),
-      ),
-    ];
-  }
-}
diff --git a/ui/src/frontend/generic_slice_details_tab.ts b/ui/src/frontend/generic_slice_details_tab.ts
index b6d72f5..fdc5bb8 100644
--- a/ui/src/frontend/generic_slice_details_tab.ts
+++ b/ui/src/frontend/generic_slice_details_tab.ts
@@ -22,27 +22,17 @@
 import {SqlRef} from '../widgets/sql_ref';
 import {dictToTree, Tree, TreeNode} from '../widgets/tree';
 
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {BottomTab, NewBottomTabArgs} from './bottom_tab';
 import {sqlValueToString} from './sql_utils';
 
-export interface ColumnConfig {
-  displayName?: string;
-}
+import {GenericSliceDetailsTabConfig} from '../core/generic_slice_details_types';
 
-export type Columns = {
-  [columnName: string]: ColumnConfig;
-};
-
-export interface GenericSliceDetailsTabConfigBase {
-  sqlTableName: string;
-  title: string;
-  // All columns are rendered if |columns| is undefined.
-  columns?: Columns;
-}
-
-export type GenericSliceDetailsTabConfig = GenericSliceDetailsTabConfigBase & {
-  id: number;
-};
+export {
+  ColumnConfig,
+  Columns,
+  GenericSliceDetailsTabConfigBase,
+  GenericSliceDetailsTabConfig,
+} from '../core/generic_slice_details_types';
 
 // A details tab, which fetches slice-like object from a given SQL table by id
 // and renders it according to the provided config, specifying which columns
@@ -126,5 +116,3 @@
     return this.data === undefined;
   }
 }
-
-bottomTabRegistry.register(GenericSliceDetailsTab);
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 4528881..b0d0982 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -14,7 +14,6 @@
 
 import {BigintMath} from '../base/bigint_math';
 import {assertExists} from '../base/logging';
-import {createStore, Store} from '../base/store';
 import {duration, Span, Time, time, TimeSpan} from '../base/time';
 import {Actions, DeferredAction} from '../common/actions';
 import {AggregateData} from '../common/aggregation_data';
@@ -31,7 +30,6 @@
 } from '../common/high_precision_time';
 import {MetricResult} from '../common/metric_data';
 import {CurrentSearchResults, SearchSummary} from '../common/search_data';
-import {onSelectionChanged} from '../common/selection_observer';
 import {
   CallsiteInfo,
   EngineConfig,
@@ -43,19 +41,18 @@
 import {TabManager} from '../common/tab_registry';
 import {TimestampFormat, timestampFormat} from '../core/timestamp_format';
 import {TrackManager} from '../common/track_cache';
-import {TABS_V2_FLAG} from '../core/feature_flags';
 import {setPerfHooks} from '../core/perf';
 import {raf} from '../core/raf_scheduler';
 import {Engine} from '../trace_processor/engine';
 import {HttpRpcState} from '../trace_processor/http_rpc_engine';
 
 import {Analytics, initAnalytics} from './analytics';
-import {BottomTabList} from './bottom_tab';
 import {Timeline} from './frontend_local_state';
 import {Router} from './router';
 import {horizontalScrollToTs} from './scroll_helper';
 import {ServiceWorkerController} from './service_worker_controller';
 import {SliceSqlId} from './sql_types';
+import {createStore, Store} from '../base/store';
 import {PxSpan, TimeScale} from './time_scale';
 
 const INSTANT_FOCUS_DURATION = 1n;
@@ -239,8 +236,6 @@
 class Globals {
   readonly root = getRoot();
 
-  bottomTabList?: BottomTabList = undefined;
-
   private _testing = false;
   private _dispatch?: Dispatch = undefined;
   private _store = createStore(createEmptyState());
@@ -623,39 +618,15 @@
   makeSelection(action: DeferredAction<{}>, opts: MakeSelectionOpts = {}) {
     const {switchToCurrentSelectionTab = true, clearSearch = true} = opts;
 
-    const previousState = this.state;
-
     const currentSelectionTabUri = 'current_selection';
 
     // A new selection should cancel the current search selection.
     clearSearch && globals.dispatch(Actions.setSearchIndex({index: -1}));
 
-    if (TABS_V2_FLAG.get()) {
-      if (action.type !== 'deselect' && switchToCurrentSelectionTab) {
-        globals.dispatch(Actions.showTab({uri: currentSelectionTabUri}));
-      }
-    } else {
-      if (action.type === 'deselect') {
-        globals.dispatch(Actions.setCurrentTab({tab: undefined}));
-      } else if (switchToCurrentSelectionTab) {
-        globals.dispatch(Actions.setCurrentTab({tab: currentSelectionTabUri}));
-      }
+    if (action.type !== 'deselect' && switchToCurrentSelectionTab) {
+      globals.dispatch(Actions.showTab({uri: currentSelectionTabUri}));
     }
     globals.dispatch(action);
-
-    // HACK(stevegolton + altimin): This is a workaround to allow passing the
-    // next tab state to the Bottom Tab API
-    const currentSelection = getLegacySelection(this.state);
-    const previousSelection = getLegacySelection(previousState);
-    if (currentSelection !== previousSelection) {
-      // TODO(altimin): Currently we are not triggering this when changing
-      // the set of selected tracks via toggling per-track checkboxes.
-      // Fix that.
-      onSelectionChanged(
-        currentSelection ?? undefined,
-        switchToCurrentSelectionTab,
-      );
-    }
   }
 
   resetForTesting() {
diff --git a/ui/src/frontend/help_modal.ts b/ui/src/frontend/help_modal.ts
index 9d6f3e6..23f1c32 100644
--- a/ui/src/frontend/help_modal.ts
+++ b/ui/src/frontend/help_modal.ts
@@ -252,6 +252,7 @@
           m('td', keycap(ctrlOrCmd), ' + ', keycap('s')),
           m('td', 'Search'),
         ),
+        m('tr', m('td', keycap('q')), m('td', 'Toggle tab drawer')),
         ...sidebarInstructions,
         m('tr', m('td', keycap('?')), m('td', 'Show help')),
       ),
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index 23dcbbc..69362e4 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -23,7 +23,7 @@
 import {raf} from '../core/raf_scheduler';
 import {Button} from '../widgets/button';
 
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {BottomTab, NewBottomTabArgs} from './bottom_tab';
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
 import {
@@ -404,7 +404,6 @@
           minimal: true,
           onclick: () => {
             globals.dispatch(Actions.removeNote({id: this.config.id}));
-            globals.dispatch(Actions.setCurrentTab({tab: undefined}));
             raf.scheduleFullRedraw();
           },
         }),
@@ -412,5 +411,3 @@
     );
   }
 }
-
-bottomTabRegistry.register(NotesEditorTab);
diff --git a/ui/src/frontend/publish.ts b/ui/src/frontend/publish.ts
index 18b5773..bf8b3a5 100644
--- a/ui/src/frontend/publish.ts
+++ b/ui/src/frontend/publish.ts
@@ -14,7 +14,7 @@
 
 import {time} from '../base/time';
 import {Actions} from '../common/actions';
-import {AggregateData, isEmptyData} from '../common/aggregation_data';
+import {AggregateData} from '../common/aggregation_data';
 import {ConversionJobStatusUpdate} from '../common/conversion_jobs';
 import {
   LogBoundsKey,
@@ -170,15 +170,11 @@
   kind: string;
 }) {
   globals.setAggregateData(args.kind, args.data);
-  if (!isEmptyData(args.data)) {
-    globals.dispatch(Actions.setCurrentTab({tab: args.data.tabName}));
-  }
   globals.publishRedraw();
 }
 
 export function publishQueryResult(args: {id: string; data?: {}}) {
   globals.queryResults.set(args.id, args.data);
-  globals.dispatch(Actions.setCurrentTab({tab: `query_result_${args.id}`}));
   globals.publishRedraw();
 }
 
@@ -195,7 +191,6 @@
   const id = click.id;
   if (id !== undefined && id === globals.state.pendingScrollId) {
     findCurrentSelection();
-    globals.dispatch(Actions.setCurrentTab({tab: 'slice'}));
     globals.dispatch(Actions.clearPendingScrollId({id: undefined}));
   }
   globals.publishRedraw();
diff --git a/ui/src/frontend/query_page.ts b/ui/src/frontend/query_page.ts
index b4b467d..7afcbaf 100644
--- a/ui/src/frontend/query_page.ts
+++ b/ui/src/frontend/query_page.ts
@@ -128,11 +128,6 @@
         : m(QueryTable, {
             query: state.executedQuery,
             resp: state.queryResult,
-            onClose: () => {
-              state.executedQuery = undefined;
-              state.queryResult = undefined;
-              raf.scheduleFullRedraw();
-            },
             fillParent: false,
           }),
       m(QueryHistoryComponent, {
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index 27e03af..f7fbf62 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -27,15 +27,8 @@
 import {PopupMenu2} from '../widgets/menu';
 import {PopupPosition} from '../widgets/popup';
 
-import {
-  addTab,
-  BottomTab,
-  bottomTabRegistry,
-  closeTab,
-  NewBottomTabArgs,
-} from './bottom_tab';
+import {BottomTab, NewBottomTabArgs} from './bottom_tab';
 import {QueryTable} from './query_table';
-import {TABS_V2_FLAG} from '../core/feature_flags';
 import {globals} from './globals';
 import {Actions} from '../common/actions';
 import {BottomTabToTabAdapter} from '../public/utils';
@@ -55,29 +48,21 @@
   config: QueryResultTabConfig,
   tag?: string,
 ): void {
-  if (TABS_V2_FLAG.get()) {
-    const queryResultsTab = new QueryResultTab({
-      config,
-      engine: getEngine(),
-      uuid: uuidv4(),
-    });
+  const queryResultsTab = new QueryResultTab({
+    config,
+    engine: getEngine(),
+    uuid: uuidv4(),
+  });
 
-    const uri = 'queryResults#' + (tag ?? uuidv4());
+  const uri = 'queryResults#' + (tag ?? uuidv4());
 
-    globals.tabManager.registerTab({
-      uri,
-      content: new BottomTabToTabAdapter(queryResultsTab),
-      isEphemeral: true,
-    });
+  globals.tabManager.registerTab({
+    uri,
+    content: new BottomTabToTabAdapter(queryResultsTab),
+    isEphemeral: true,
+  });
 
-    globals.dispatch(Actions.showTab({uri}));
-  } else {
-    return addTab({
-      kind: QueryResultTab.kind,
-      tag,
-      config,
-    });
-  }
+  globals.dispatch(Actions.showTab({uri}));
 }
 
 // TODO(stevegolton): Find a way to make this more elegant.
@@ -139,7 +124,6 @@
       query: this.config.query,
       resp: this.queryResponse,
       fillParent: true,
-      onClose: () => closeTab(this.uuid),
       contextButtons: [
         this.sqlViewName === undefined
           ? null
@@ -192,5 +176,3 @@
     return viewId;
   }
 }
-
-bottomTabRegistry.register(QueryResultTab);
diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts
index 0bbd710..4affa45 100644
--- a/ui/src/frontend/query_table.ts
+++ b/ui/src/frontend/query_table.ts
@@ -211,7 +211,6 @@
 
 interface QueryTableAttrs {
   query: string;
-  onClose: () => void;
   resp?: QueryResponse;
   contextButtons?: m.Child[];
   fillParent: boolean;
@@ -219,14 +218,14 @@
 
 export class QueryTable implements m.ClassComponent<QueryTableAttrs> {
   view({attrs}: m.CVnode<QueryTableAttrs>) {
-    const {resp, query, onClose, contextButtons = [], fillParent} = attrs;
+    const {resp, query, contextButtons = [], fillParent} = attrs;
 
     return m(
       DetailsShell,
       {
         title: this.renderTitle(resp),
         description: query,
-        buttons: this.renderButtons(query, onClose, contextButtons, resp),
+        buttons: this.renderButtons(query, contextButtons, resp),
         fillParent,
       },
       resp && this.renderTableContent(resp),
@@ -243,7 +242,6 @@
 
   renderButtons(
     query: string,
-    onClose: () => void,
     contextButtons: m.Child[],
     resp?: QueryResponse,
   ) {
@@ -265,11 +263,6 @@
             queryResponseToClipboard(resp);
           },
         }),
-      m(Button, {
-        minimal: true,
-        label: 'Close',
-        onclick: onClose,
-      }),
     ];
   }
 
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
index ac9353b..01c87af 100644
--- a/ui/src/frontend/sql_table/tab.ts
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -21,23 +21,17 @@
 import {Button} from '../../widgets/button';
 import {DetailsShell} from '../../widgets/details_shell';
 import {Popup, PopupPosition} from '../../widgets/popup';
-import {
-  addTab,
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../bottom_tab';
 
 import {Filter, SqlTableState} from './state';
 import {SqlTable} from './table';
 import {SqlTableDescription, tableDisplayName} from './table_description';
-import {TABS_V2_FLAG} from '../../core/feature_flags';
 import {EngineProxy} from '../../public';
 import {globals} from '../globals';
 import {assertExists} from '../../base/logging';
 import {uuidv4} from '../../base/uuid';
 import {BottomTabToTabAdapter} from '../../public/utils';
 import {Actions} from '../../common/actions';
+import {BottomTab, NewBottomTabArgs} from '../bottom_tab';
 
 interface SqlTableTabConfig {
   table: SqlTableDescription;
@@ -46,28 +40,21 @@
 }
 
 export function addSqlTableTab(config: SqlTableTabConfig): void {
-  if (TABS_V2_FLAG.get()) {
-    const queryResultsTab = new SqlTableTab({
-      config,
-      engine: getEngine(),
-      uuid: uuidv4(),
-    });
+  const queryResultsTab = new SqlTableTab({
+    config,
+    engine: getEngine(),
+    uuid: uuidv4(),
+  });
 
-    const uri = 'sqlTable#' + uuidv4();
+  const uri = 'sqlTable#' + uuidv4();
 
-    globals.tabManager.registerTab({
-      uri,
-      content: new BottomTabToTabAdapter(queryResultsTab),
-      isEphemeral: true,
-    });
+  globals.tabManager.registerTab({
+    uri,
+    content: new BottomTabToTabAdapter(queryResultsTab),
+    isEphemeral: true,
+  });
 
-    globals.dispatch(Actions.showTab({uri}));
-  } else {
-    return addTab({
-      kind: SqlTableTab.kind,
-      config,
-    });
-  }
+  globals.dispatch(Actions.showTab({uri}));
 }
 
 // TODO(stevegolton): Find a way to make this more elegant.
@@ -145,10 +132,6 @@
             onclick: () =>
               copyToClipboard(this.state.getNonPaginatedSQLQuery()),
           }),
-          m(Button, {
-            label: 'Close',
-            onclick: () => this.close(),
-          }),
         ],
       },
       m(SqlTable, {
@@ -171,5 +154,3 @@
     return this.state.isLoading();
   }
 }
-
-bottomTabRegistry.register(SqlTableTab);
diff --git a/ui/src/frontend/thread_state_tab.ts b/ui/src/frontend/thread_state_tab.ts
index c34c92d..30209ab 100644
--- a/ui/src/frontend/thread_state_tab.ts
+++ b/ui/src/frontend/thread_state_tab.ts
@@ -25,7 +25,7 @@
 import {SqlRef} from '../widgets/sql_ref';
 import {Tree, TreeNode} from '../widgets/tree';
 
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {BottomTab, NewBottomTabArgs} from './bottom_tab';
 import {SchedSqlId, ThreadStateSqlId} from './sql_types';
 import {
   getFullThreadName,
@@ -387,5 +387,3 @@
     return this.state === undefined || this.relatedStates === undefined;
   }
 }
-
-bottomTabRegistry.register(ThreadStateTab);
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index ef2686a..6cd0fb4 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -19,12 +19,11 @@
 import {Time} from '../base/time';
 import {Actions} from '../common/actions';
 import {TrackCacheEntry} from '../common/track_cache';
-import {TABS_V2_FLAG, featureFlags} from '../core/feature_flags';
+import {featureFlags} from '../core/feature_flags';
 import {raf} from '../core/raf_scheduler';
 import {TrackTags} from '../public';
 
 import {TRACK_SHELL_WIDTH} from './css_constants';
-import {DetailsPanel} from './details_panel';
 import {globals} from './globals';
 import {NotesPanel} from './notes_panel';
 import {OverviewTimelinePanel} from './overview_timeline_panel';
@@ -366,11 +365,7 @@
   }
 
   private renderTabPanel() {
-    if (TABS_V2_FLAG.get()) {
-      return m(TabPanel);
-    } else {
-      return m(DetailsPanel);
-    }
+    return m(TabPanel);
   }
 }
 
diff --git a/ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts b/ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts
index 34b5451..4aee5ce 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts
@@ -14,11 +14,7 @@
 
 import m from 'mithril';
 
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {Details, DetailsSchema} from '../../frontend/sql/details/details';
 import {wellKnownTypes} from '../../frontend/sql/details/well_known_types';
@@ -93,5 +89,3 @@
     return this.data.isLoading();
   }
 }
-
-bottomTabRegistry.register(PageLoadDetailsPanel);
diff --git a/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts b/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
index 621ec36..4312079 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
@@ -15,11 +15,7 @@
 import m from 'mithril';
 
 import {duration, Time, time} from '../../base/time';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {DurationWidget} from '../../frontend/widgets/duration';
 import {Timestamp} from '../../frontend/widgets/timestamp';
@@ -148,5 +144,3 @@
     return !this.loaded;
   }
 }
-
-bottomTabRegistry.register(StartupDetailsPanel);
diff --git a/ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts b/ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
index 26ac119..7cf7b48 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
@@ -29,11 +29,7 @@
 import m from 'mithril';
 
 import {duration, Time, time} from '../../base/time';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {asUpid, Upid} from '../../frontend/sql_types';
 import {DurationWidget} from '../../frontend/widgets/duration';
@@ -149,5 +145,3 @@
     return !this.loaded;
   }
 }
-
-bottomTabRegistry.register(WebContentInteractionPanel);
diff --git a/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts b/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
index d638580..5040802 100644
--- a/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
+++ b/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
@@ -16,11 +16,7 @@
 
 import {Duration, duration, time} from '../../base/time';
 import {raf} from '../../core/raf_scheduler';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {hasArgs, renderArguments} from '../../frontend/slice_args';
 import {renderDetails} from '../../frontend/slice_details';
@@ -547,5 +543,3 @@
     return `Current Selection`;
   }
 }
-
-bottomTabRegistry.register(EventLatencySliceDetailsPanel);
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts b/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
index 41cef53..d1bfb8c 100644
--- a/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
@@ -17,11 +17,7 @@
 import {duration, Time, time} from '../../base/time';
 import {exists} from '../../base/utils';
 import {raf} from '../../core/raf_scheduler';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {sqlValueToString} from '../../frontend/sql_utils';
 import {
@@ -447,5 +443,3 @@
     return !this.loaded;
   }
 }
-
-bottomTabRegistry.register(ScrollDetailsPanel);
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts b/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
index 4818327..e47e75c 100644
--- a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
@@ -17,11 +17,7 @@
 import {duration, Time, time} from '../../base/time';
 import {exists} from '../../base/utils';
 import {raf} from '../../core/raf_scheduler';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {getSlice, SliceDetails} from '../../frontend/sql/slice';
 import {asSliceSqlId} from '../../frontend/sql_types';
@@ -342,5 +338,3 @@
     return !this.loaded;
   }
 }
-
-bottomTabRegistry.register(ScrollJankV3DetailsPanel);
diff --git a/ui/src/tracks/debug/details_tab.ts b/ui/src/tracks/debug/details_tab.ts
index 91ae02b..659c48c 100644
--- a/ui/src/tracks/debug/details_tab.ts
+++ b/ui/src/tracks/debug/details_tab.ts
@@ -16,11 +16,7 @@
 
 import {duration, Time, time} from '../../base/time';
 import {raf} from '../../core/raf_scheduler';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {hasArgs, renderArguments} from '../../frontend/slice_args';
 import {getSlice, SliceDetails, sliceRef} from '../../frontend/sql/slice';
@@ -279,5 +275,3 @@
     return this.data === undefined;
   }
 }
-
-bottomTabRegistry.register(DebugSliceDetailsTab);
diff --git a/ui/src/tracks/screenshots/screenshot_panel.ts b/ui/src/tracks/screenshots/screenshot_panel.ts
index 444ca30..066d14c 100644
--- a/ui/src/tracks/screenshots/screenshot_panel.ts
+++ b/ui/src/tracks/screenshots/screenshot_panel.ts
@@ -16,11 +16,7 @@
 
 import {assertTrue} from '../../base/logging';
 import {exists} from '../../base/utils';
-import {
-  BottomTab,
-  bottomTabRegistry,
-  NewBottomTabArgs,
-} from '../../frontend/bottom_tab';
+import {BottomTab, NewBottomTabArgs} from '../../frontend/bottom_tab';
 import {GenericSliceDetailsTabConfig} from '../../frontend/generic_slice_details_tab';
 import {getSlice, SliceDetails} from '../../frontend/sql/slice';
 import {asSliceSqlId} from '../../frontend/sql_types';
@@ -74,5 +70,3 @@
     );
   }
 }
-
-bottomTabRegistry.register(ScreenshotTab);