tp,ui: add PROFILE_FROM_TREE aggregate and expose it in flamegraphs PROFILE_FROM_TREE(id, parent_id, frame_name, self_value, sample_type, unit) is a new SQL aggregate that converts any tree-shaped table into a serialized pprof Profile. The stdlib module `pprof.from_tree` exposes it as `_pprof_from_tree!`, composing on top of the `std.trees.*` operators so users can filter, propagate, and emit pprof through the same primitives. The Flamegraph widget gains a "Download as pprof" toolbar button. Encoding lives in the engine; the widget delegates via a callback so the bytes always match the SQL surface. The SQL query results panel and SQL table tab gain an "Add Flamegraph" control mirroring "Add debug track": the user picks the id/parent_id/name/value columns of any query, optionally overrides sample type and unit, and the result opens as an ephemeral flamegraph tab that inherits the toolbar Download button. Documentation under analysis/pprof-from-tree.md describes the SQL surface and walks through recipes for heap dumps, callstack samples, message-queue stacks, and Java heap dominator trees. Diff tests cover the aggregate and the stdlib macro. Change-Id: I27106469fc9c74dc45f494ed87a70c3b76f9c089
diff --git a/Android.bp b/Android.bp index 0f52205..58fb4b0 100644 --- a/Android.bp +++ b/Android.bp
@@ -17048,6 +17048,14 @@ ], } +// GN: //src/trace_processor/perfetto_sql/stdlib/pprof:pprof +filegroup { + name: "perfetto_src_trace_processor_perfetto_sql_stdlib_pprof_pprof", + srcs: [ + "src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql", + ], +} + // GN: //src/trace_processor/perfetto_sql/stdlib/prelude:prelude filegroup { name: "perfetto_src_trace_processor_perfetto_sql_stdlib_prelude_prelude", @@ -17169,6 +17177,7 @@ ":perfetto_src_trace_processor_perfetto_sql_stdlib_linux_linux", ":perfetto_src_trace_processor_perfetto_sql_stdlib_pixel_pixel", ":perfetto_src_trace_processor_perfetto_sql_stdlib_pkvm_pkvm", + ":perfetto_src_trace_processor_perfetto_sql_stdlib_pprof_pprof", ":perfetto_src_trace_processor_perfetto_sql_stdlib_prelude_prelude", ":perfetto_src_trace_processor_perfetto_sql_stdlib_sched_sched", ":perfetto_src_trace_processor_perfetto_sql_stdlib_slices_slices",
diff --git a/BUILD b/BUILD index 4afa527..0d15165 100644 --- a/BUILD +++ b/BUILD
@@ -3981,6 +3981,14 @@ ], ) +# GN target: //src/trace_processor/perfetto_sql/stdlib/pprof:pprof +perfetto_filegroup( + name = "src_trace_processor_perfetto_sql_stdlib_pprof_pprof", + srcs = [ + "src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql", + ], +) + # GN target: //src/trace_processor/perfetto_sql/stdlib/prelude/after_eof:after_eof perfetto_filegroup( name = "src_trace_processor_perfetto_sql_stdlib_prelude_after_eof_after_eof", @@ -4203,6 +4211,7 @@ ":src_trace_processor_perfetto_sql_stdlib_linux_linux", ":src_trace_processor_perfetto_sql_stdlib_pixel_pixel", ":src_trace_processor_perfetto_sql_stdlib_pkvm_pkvm", + ":src_trace_processor_perfetto_sql_stdlib_pprof_pprof", ":src_trace_processor_perfetto_sql_stdlib_prelude_prelude", ":src_trace_processor_perfetto_sql_stdlib_sched_sched", ":src_trace_processor_perfetto_sql_stdlib_slices_slices",
diff --git a/docs/analysis/pprof-from-tree.md b/docs/analysis/pprof-from-tree.md new file mode 100644 index 0000000..2428d49 --- /dev/null +++ b/docs/analysis/pprof-from-tree.md
@@ -0,0 +1,293 @@ +# Generating pprof from any tree + +PerfettoSQL ships a `PROFILE_FROM_TREE` aggregate that converts any +`(id, parent_id, frame_name, value)` hierarchy into a serialized +[pprof Profile](https://github.com/google/pprof/blob/main/proto/profile.proto). +A thin stdlib layer (`pprof.from_tree`) wraps it on top of the existing +`std.trees.*` operators (`_tree_from_table`, `_tree_filter`, +`_tree_propagate_down`) so all pprof queries share one composable +pipeline: + +``` + raw rows tree pointer + ┌───────────────────────────┐ _tree_from_table! ┌──────────┐ + │ (id, parent_id, name, │ ────────────────────► │ TREE_PTR │ + │ value, …) │ └────┬─────┘ + └───────────────────────────┘ │ + optional ┌─────┴────────┐ + composition │ _tree_filter │ + │ _tree_propagate_down + └─────┬────────┘ + │ + _pprof_from_tree!│ + ▼ + pprof bytes +``` + +The Perfetto UI exposes the same primitive in two places: + +- A permanent **Download as pprof** button in the top-right of every + flamegraph (heap profile, perf samples, java heap, slice flamegraph, + and any user-driven flamegraph). +- An **Add flamegraph** popup on every data-grid surface (SQL query + results panel, SQL table tab) that lets the user pick the + `id`/`parent_id`/`name`/`value` columns and the metric `sample_type` + / `unit`, then opens a flamegraph tab driven by those columns. The + resulting tab inherits the toolbar Download button. + +## SQL surface + +Two layers, with composition flowing through `std.trees.*`: + +```sql +-- Layer 1: raw aggregate, no stdlib dependency. +SELECT PROFILE_FROM_TREE( + id, -- INTEGER, unique per row + parent_id, -- INTEGER NULL, NULL marks a root + name, -- TEXT NULL, frame label + value, -- INTEGER NULL, rows with NULL or value <= 0 emit + -- no Sample but remain available as ancestors + sample_type,-- TEXT, e.g. 'space', 'allocations', 'wall' + unit -- TEXT, e.g. 'bytes', 'count', 'nanoseconds' +) +FROM tree; + +-- Layer 2: stdlib macro for queries that compose with std.trees.*. +INCLUDE PERFETTO MODULE pprof.from_tree; +SELECT _pprof_from_tree!( + _tree_from_table!( + (SELECT id, parent_id, name, dur AS value + FROM slice WHERE dur > 0), + (name, value)), + name, value, -- column names inside the tree pointer + 'wall', 'nanoseconds'); +``` + +Both return a BLOB of raw (uncompressed) pprof Profile bytes. + +### Errors + +The aggregate fails the query (with a clear message) when: + +- two rows share the same `id` +- a non-NULL `parent_id` references an `id` not present in the input +- the parent chain of a sample contains a cycle + +## Recipes + +Save any of the recipes below to a file and run them through +`trace_processor_shell -Q`. The aggregate returns a BLOB of raw +Profile bytes, which the shell prints as a hex string; redirect to a +file, decode with the snippet below, and inspect with `pprof`: + +```sh +out/linux/trace_processor_shell -Q query.sql trace.pftrace > /tmp/out.hex +python3 -c "import re; \ + d=open('/tmp/out.hex').read(); \ + open('/tmp/out.pb','wb').write(bytes.fromhex(re.sub(r'[^0-9A-Fa-f]','', + [l for l in d.splitlines() if l.startswith('\"')][1].strip('\"'))))" +pprof -text /tmp/out.pb +``` + +### 1. Native heap dump (heapprofd) + +```sql +INCLUDE PERFETTO MODULE pprof.from_tree; + +SELECT _pprof_from_tree!( + _tree_from_table!( + (SELECT + c.id AS id, + c.parent_id AS parent_id, + COALESCE(f.name, '<anon>') AS name, + COALESCE( + (SELECT SUM(size) FROM heap_profile_allocation a + WHERE a.callsite_id = c.id AND a.size > 0), 0) AS value + FROM stack_profile_callsite c + JOIN stack_profile_frame f ON f.id = c.frame_id), + (name, value)), + name, value, 'space', 'bytes'); +``` + +`pprof -text` of the resulting profile (system_server heap dump from +cuttlefish): + +``` +Type: space +Showing nodes accounting for 42kB, 100% of 42kB total + flat flat% sum% cum cum% + 42kB 100% 100% 42kB 100% malloc + 0 0% 100% 42kB 100% __pthread_start + 0 0% 100% 14kB 33.33% android::BinderObserver::flushStats + 0 0% 100% 10kB 23.81% android::BinderStatsCollector::consumeData +``` + +### 2. Callstack sampling (linux.perf) + +```sql +INCLUDE PERFETTO MODULE pprof.from_tree; + +SELECT _pprof_from_tree!( + _tree_from_table!( + (SELECT + c.id AS id, + c.parent_id AS parent_id, + COALESCE(f.name, '<unknown>') AS name, + COALESCE( + (SELECT COUNT(*) FROM perf_sample p + WHERE p.callsite_id = c.id), 0) AS value + FROM stack_profile_callsite c + JOIN stack_profile_frame f ON f.id = c.frame_id), + (name, value)), + name, value, 'samples', 'count'); +``` + +`pprof -text` of a 5s callstack-sampling capture targeting +`system_server` on cuttlefish: + +``` +Type: samples +Showing nodes accounting for 7, 100% of 7 total + flat flat% sum% cum cum% + 2 28.57% 28.57% 2 28.57% _raw_spin_unlock_irqrestore + 1 14.29% 42.86% 5 71.43% do_syscall_64 + 1 14.29% 57.14% 1 14.29% art::InvokeVirtualOrInterfaceWithVarArgs + 1 14.29% 71.43% 1 14.29% avc_has_perm_noaudit +``` + +### 3. Message-queue stacks (track events + flow) + +Cross-thread message handling does not fit in the parent/child slice +hierarchy: the producer of work and the consumer live on different +threads. Track events emit `message_queue_send` slices linked by +`flow` rows to the matching `message_queue_receive` slices, and a new +send issued from inside a receive chains back to its incoming receive +via `ancestor_slice`. Composing those two edge sets gives a real +cross-thread call tree; counting one unit at every leaf produces a +"messages delivered" pprof rooted at the originating thread: + +```sql +INCLUDE PERFETTO MODULE pprof.from_tree; +INCLUDE PERFETTO MODULE slices.with_context; + +WITH + -- send -> receive: from the flow attached to the send. + send_to_receive AS ( + SELECT DISTINCT slice_out AS parent_id, slice_in AS id + FROM slice + JOIN flow ON slice_out = slice.id + WHERE slice.name = 'message_queue_send' + ), + -- receive -> next send: a send started under an enclosing receive + -- inherits that receive as its parent in the cross-thread stack. + receive_to_send AS ( + SELECT s2r.parent_id AS parent_id, slice.id + FROM slice + JOIN ancestor_slice(slice.id) anc + JOIN flow ON anc.id = flow.slice_in + JOIN send_to_receive s2r ON s2r.id = anc.id + WHERE anc.name = 'message_queue_receive' + AND slice.name = 'message_queue_send' + ), + edges AS ( + SELECT parent_id, id FROM send_to_receive + UNION + SELECT parent_id, id FROM receive_to_send + ) +SELECT _pprof_from_tree!( + _tree_from_table!( + (SELECT + e.id, + e.parent_id, + FORMAT('%s/%s', t.process_name, t.thread_name) AS name, + (NOT EXISTS (SELECT 1 FROM edges c WHERE c.parent_id = e.id)) AS value + FROM edges e + LEFT JOIN thread_slice t ON t.id = e.id), + (name, value)), + name, value, 'messages', 'count'); +``` + +### 4. Java heap dominator tree + +The dominator tree of a Java heap is exposed by +`android.memory.heap_graph.dominator_tree`. Combine it with object +type names and self-sizes: + +```sql +INCLUDE PERFETTO MODULE android.memory.heap_graph.dominator_tree; +INCLUDE PERFETTO MODULE pprof.from_tree; + +SELECT _pprof_from_tree!( + _tree_from_table!( + (SELECT + d.id AS id, + d.idom_id AS parent_id, + COALESCE(c.name, '<unknown>') AS name, + o.self_size AS value + FROM heap_graph_dominator_tree d + JOIN heap_graph_object o USING (id) + LEFT JOIN heap_graph_class c ON c.id = o.type_id), + (name, value)), + name, value, 'space', 'bytes'); +``` + +`pprof -text` of a 64MB Java heap dump from `system_server`: + +``` +Type: space +Showing nodes accounting for 114.31MB, 90.50% of 126.31MB total +Dropped 12426 nodes (cum <= 0.63MB) + flat flat% sum% cum cum% + 60.86MB 48.18% 48.18% 60.86MB 48.18% double[] + 11.78MB 9.32% 57.50% 13.05MB 10.33% java.lang.Class + 8.87MB 7.03% 64.53% 8.87MB 7.03% android.location.GnssAntennaInfo$PhaseCenterOffset + 7.61MB 6.02% 70.55% 68.46MB 54.20% double[][] + 7.33MB 5.80% 76.35% 7.33MB 5.80% java.lang.String + 5.07MB 4.01% 80.37% 86.20MB 68.25% android.location.GnssAntennaInfo$Builder + 3.80MB 3.01% 83.38% 72.26MB 57.21% android.location.GnssAntennaInfo$SphericalCorrections +``` + +## UI + +### Download as pprof + +The Perfetto Flamegraph widget renders a `download` icon button in its +toolbar (top-right of the filter bar). Clicking it runs the active +metric's tree through the C++ `profile_from_tree` aggregate and +triggers a browser download named `<metric>.pb`. The bytes can be +inspected with: + +```sh +go install github.com/google/pprof@latest +pprof -text downloaded.pb +``` + +The export delegates to the engine instead of re-encoding the proto in +TypeScript, so the bytes are guaranteed to match what the SQL surface +emits. UI filters are not applied to the export; the full tree +underlying the metric is what's written to disk. + +### Add flamegraph (every data grid) + +Wherever the existing **Add debug track** popup is exposed (the SQL +query result panel, the SQL table tab), there is now a sibling +**Add flamegraph** popup. The form has a title field, dropdowns to +choose the `id` / `parent_id` / `name` / `value` columns from the +underlying query (auto-picked when columns of those names exist), and +two free-text fields for the metric `sample_type` and `unit`. + +Submitting opens an ephemeral tab driven by the chosen columns; the +tab uses the same `Flamegraph` widget so the Download as pprof button +is available immediately. + +## Verification + +```sh +tools/gn gen out/linux +tools/ninja -C out/linux trace_processor_shell + +out/linux/trace_processor_shell -Q query.sql /path/to/trace.pftrace \ + > /tmp/out.hex +# Decode hex to binary; see the Recipes section above for the snippet. +pprof -text /tmp/out.pb +```
diff --git a/docs/toc.md b/docs/toc.md index 4e2a858..8994ef6 100644 --- a/docs/toc.md +++ b/docs/toc.md
@@ -120,6 +120,7 @@ - [Batch Trace Processor](analysis/batch-trace-processor.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf} - [Trace Summarization](analysis/trace-summary.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf} + - [Generating pprof from any tree](analysis/pprof-from-tree.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf} - [Converting from Perfetto](quickstart/traceconv.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome} - [FAQ](faq.md) {.tag-android .tag-linux .tag-cpp-rust .tag-chrome .tag-perf}
diff --git a/src/trace_processor/perfetto_sql/stdlib/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/BUILD.gn index db575b4..fa4dbaf 100644 --- a/src/trace_processor/perfetto_sql/stdlib/BUILD.gn +++ b/src/trace_processor/perfetto_sql/stdlib/BUILD.gn
@@ -30,6 +30,7 @@ "linux", "pixel", "pkvm", + "pprof", "prelude", "sched", "slices",
diff --git a/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn b/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn new file mode 100644 index 0000000..33c7c44 --- /dev/null +++ b/src/trace_processor/perfetto_sql/stdlib/pprof/BUILD.gn
@@ -0,0 +1,19 @@ +# Copyright (C) 2026 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/perfetto_sql.gni") + +perfetto_sql_source_set("pprof") { + sources = [ "from_tree.sql" ] +}
diff --git a/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql b/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql new file mode 100644 index 0000000..56b7b6c --- /dev/null +++ b/src/trace_processor/perfetto_sql/stdlib/pprof/from_tree.sql
@@ -0,0 +1,73 @@ +-- +-- Copyright 2026 The Android Open Source Project +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- https://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +INCLUDE PERFETTO MODULE std.trees.table_conversion; + +-- Emit a serialized pprof Profile proto from a TREE pointer produced by +-- the `std.trees.*` operators (`_tree_from_table`, `_tree_filter`, +-- `_tree_propagate_down`, ...). +-- +-- The pointer must carry the columns named by `name_col` (TEXT, frame +-- label) and `value_col` (INTEGER, self contribution). Rows whose value +-- is NULL or non-positive emit no Sample but remain available as +-- ancestors of deeper rows. +-- +-- Returns a BLOB of raw (uncompressed) Profile proto bytes. +-- +-- Simple recipe — wrap any (id, parent_id, ...) table: +-- +-- SELECT _pprof_from_tree!( +-- _tree_from_table!( +-- (SELECT id, parent_id, name, dur AS value +-- FROM slice WHERE dur > 0), +-- (name, value) +-- ), +-- name, value, 'wall', 'nanoseconds' +-- ); +-- +-- Composable recipe — drop slices below 1ms then emit pprof: +-- +-- SELECT _pprof_from_tree!( +-- _tree_filter( +-- _tree_from_table!( +-- (SELECT id, parent_id, name, dur AS value FROM slice), +-- (name, value) +-- ), +-- _tree_where(_tree_constraint('value', '>', 1000000)) +-- ), +-- name, value, 'wall', 'nanoseconds' +-- ); +-- +-- For callers that don't want to take a dependency on `std.trees.*`, +-- the underlying `profile_from_tree(id, parent_id, name, value, +-- sample_type, unit)` aggregate is callable directly. +CREATE PERFETTO MACRO _pprof_from_tree( + -- TREE pointer produced by an operator in `std.trees.*`. + tree_ptr Expr, + -- Frame-name column inside the tree pointer. + name_col ColumnName, + -- Value column inside the tree pointer. + value_col ColumnName, + -- Sample type label: appears in pprof's sample_type[0].type. + sample_type Expr, + -- Sample unit: appears in pprof's sample_type[0].unit (e.g. 'bytes'). + unit Expr +) +RETURNS Expr +AS ( + SELECT + profile_from_tree(id, parent_id, $name_col, $value_col, $sample_type, $unit) + FROM _tree_to_table!($tree_ptr, ($name_col, $value_col)) +);
diff --git a/src/trace_processor/plugins/pprof_functions/BUILD.gn b/src/trace_processor/plugins/pprof_functions/BUILD.gn index 4b40739..ed0002b 100644 --- a/src/trace_processor/plugins/pprof_functions/BUILD.gn +++ b/src/trace_processor/plugins/pprof_functions/BUILD.gn
@@ -24,8 +24,10 @@ deps = [ "../../../../gn:default_deps", "../../../../gn:sqlite", + "../../../../include/perfetto/protozero", "../../../../include/perfetto/trace_processor:basic_types", "../../../../protos/perfetto/trace_processor:zero", + "../../../../protos/third_party/pprof:zero", "../../../base", "../../core/plugin", "../../perfetto_sql/engine",
diff --git a/src/trace_processor/plugins/pprof_functions/pprof_functions.cc b/src/trace_processor/plugins/pprof_functions/pprof_functions.cc index f89c28c..09acaa2 100644 --- a/src/trace_processor/plugins/pprof_functions/pprof_functions.cc +++ b/src/trace_processor/plugins/pprof_functions/pprof_functions.cc
@@ -16,11 +16,15 @@ #include "src/trace_processor/plugins/pprof_functions/pprof_functions.h" +#include <cinttypes> #include <cstddef> #include <cstdint> #include <cstdlib> #include <memory> +#include <optional> #include <string> +#include <unordered_map> +#include <unordered_set> #include <utility> #include <vector> @@ -29,8 +33,11 @@ #include "perfetto/base/status.h" #include "perfetto/ext/base/status_macros.h" #include "perfetto/ext/base/status_or.h" +#include "perfetto/protozero/packed_repeated_fields.h" +#include "perfetto/protozero/scattered_heap_buffer.h" #include "perfetto/trace_processor/basic_types.h" #include "protos/perfetto/trace_processor/stack.pbzero.h" +#include "protos/third_party/pprof/profile.pbzero.h" #include "src/trace_processor/core/plugin/plugin.h" #include "src/trace_processor/perfetto_sql/engine/perfetto_sql_connection.h" #include "src/trace_processor/sqlite/bindings/sqlite_aggregate_function.h" @@ -202,6 +209,289 @@ } }; +// Aggregate that converts an `(id, parent_id, frame_name, self_value)` tree +// — together with constant `sample_type` and `unit` strings — into a +// serialized pprof Profile proto. +// +// Conventions: +// - `id` must be unique per row (duplicates fail the aggregate). +// - `parent_id` NULL marks a root; multiple roots are allowed. +// - `parent_id` referencing an unknown id fails the aggregate. +// - `self_value` <= 0 (or NULL) means no Sample is emitted for that node +// but the location is still available as an ancestor for samples below. +// - `sample_type` and `unit` are read from the first row only; SQL callers +// are expected to pass them as constants. Later rows are not re-checked. +// +// Order independence: rows are buffered during Step and resolved in Final, +// so any SQL ordering is correct. +class TreeAggregateContext { + public: + base::Status Step(size_t argc, sqlite3_value** argv) { + if (argc != 6) { + return base::ErrStatus( + "PROFILE_FROM_TREE: expected 6 args (id, parent_id, frame_name, " + "self_value, sample_type, unit); got %zu", + argc); + } + + base::StatusOr<SqlValue> id = + sqlite::utils::ExtractArgument(argc, argv, "id", 0, SqlValue::kLong); + if (!id.ok()) { + return id.status(); + } + + Node node; + node.id = id->AsLong(); + + if (sqlite3_value_type(argv[1]) != SQLITE_NULL) { + base::StatusOr<SqlValue> parent_id = sqlite::utils::ExtractArgument( + argc, argv, "parent_id", 1, SqlValue::kLong); + if (!parent_id.ok()) { + return parent_id.status(); + } + node.parent_id = parent_id->AsLong(); + } + + if (sqlite3_value_type(argv[2]) != SQLITE_NULL) { + base::StatusOr<SqlValue> name = sqlite::utils::ExtractArgument( + argc, argv, "frame_name", 2, SqlValue::kString); + if (!name.ok()) { + return name.status(); + } + node.name = name->AsString(); + } + + if (sqlite3_value_type(argv[3]) != SQLITE_NULL) { + base::StatusOr<SqlValue> value = sqlite::utils::ExtractArgument( + argc, argv, "self_value", 3, SqlValue::kLong); + if (!value.ok()) { + return value.status(); + } + node.self_value = value->AsLong(); + } + + if (sample_type_.empty()) { + base::StatusOr<SqlValue> stype = sqlite::utils::ExtractArgument( + argc, argv, "sample_type", 4, SqlValue::kString); + if (!stype.ok()) { + return stype.status(); + } + sample_type_ = stype->AsString(); + + base::StatusOr<SqlValue> u = sqlite::utils::ExtractArgument( + argc, argv, "unit", 5, SqlValue::kString); + if (!u.ok()) { + return u.status(); + } + unit_ = u->AsString(); + } + + auto [_, inserted] = id_to_index_.emplace(node.id, nodes_.size()); + if (!inserted) { + return base::ErrStatus("PROFILE_FROM_TREE: duplicate id %" PRId64, + node.id); + } + nodes_.push_back(std::move(node)); + return base::OkStatus(); + } + + void Final(sqlite3_context* ctx) { + base::Status status = Build(ctx); + if (!status.ok()) { + sqlite::utils::SetError(ctx, "PROFILE_FROM_TREE", status); + } + } + + private: + struct Node { + int64_t id = 0; + std::optional<int64_t> parent_id; + std::string name; + int64_t self_value = 0; + }; + + // Adds `s` to the staged string_table if not already present. Indices + // are 0-based; index 0 is always "" per the pprof format. + int64_t InternString(const std::string& s) { + auto it = string_index_.find(s); + if (it != string_index_.end()) { + return it->second; + } + auto index = static_cast<int64_t>(string_table_.size()); + string_table_.push_back(s); + string_index_[s] = index; + return index; + } + + base::Status Build(sqlite3_context* ctx) { + protozero::HeapBuffered<third_party::perftools::profiles::pbzero::Profile> + profile; + + // protozero only allows one open child submessage at a time. We + // therefore stage every string in `string_table_` first, so writing + // a submessage never needs to insert a new top-level string_table + // field while the child is still open. + InternString(""); + + if (sample_type_.empty()) { + // No rows. Emit a valid, empty Profile (just the empty string). + profile->add_string_table(string_table_[0]); + std::string out = profile.SerializeAsString(); + sqlite::result::TransientBytes(ctx, out.data(), + static_cast<int>(out.size())); + return base::OkStatus(); + } + + int64_t type_idx = InternString(sample_type_); + int64_t unit_idx = InternString(unit_); + + // Stage one Function per unique frame_name and remember the + // assigned function id keyed by name. Function ids start at 1. + std::unordered_map<std::string, uint64_t> name_to_function_id; + struct StagedFunction { + uint64_t id; + int64_t name_idx; + }; + std::vector<StagedFunction> staged_functions; + auto get_function_id = [&](const std::string& name) -> uint64_t { + auto it = name_to_function_id.find(name); + if (it != name_to_function_id.end()) { + return it->second; + } + uint64_t id = name_to_function_id.size() + 1; + name_to_function_id[name] = id; + staged_functions.push_back({id, InternString(name)}); + return id; + }; + + // Location id == nodes_index + 1 (dense, stable). + std::vector<uint64_t> location_function_id(nodes_.size()); + for (size_t i = 0; i < nodes_.size(); ++i) { + location_function_id[i] = get_function_id(nodes_[i].name); + } + + // Validate the parent chain: every non-NULL parent_id must point at + // a known id. Cycle detection is deferred to the sample walk where + // it is per-sample. + std::vector<std::optional<size_t>> parent_index(nodes_.size()); + for (size_t i = 0; i < nodes_.size(); ++i) { + const auto& n = nodes_[i]; + if (!n.parent_id) { + continue; + } + auto it = id_to_index_.find(*n.parent_id); + if (it == id_to_index_.end()) { + return base::ErrStatus("PROFILE_FROM_TREE: id %" PRId64 + " has parent_id %" PRId64 + " which was not seen in the input", + n.id, *n.parent_id); + } + parent_index[i] = it->second; + } + + { + auto* st = profile->add_sample_type(); + st->set_type(type_idx); + st->set_unit(unit_idx); + } + + for (const auto& fn : staged_functions) { + auto* f = profile->add_function(); + f->set_id(fn.id); + f->set_name(fn.name_idx); + f->set_system_name(fn.name_idx); + } + + for (size_t i = 0; i < nodes_.size(); ++i) { + auto* loc = profile->add_location(); + loc->set_id(static_cast<uint64_t>(i + 1)); + auto* line = loc->add_line(); + line->set_function_id(location_function_id[i]); + } + + // For every node with a positive self_value emit one Sample whose + // location stack is the path from the node up to the root. pprof's + // Sample.location_id and Sample.value are packed-varint repeated + // fields; pbzero exposes them via PackedVarInt + set_*. + for (size_t i = 0; i < nodes_.size(); ++i) { + const auto& n = nodes_[i]; + if (n.self_value <= 0) { + continue; + } + protozero::PackedVarInt locs; + std::unordered_set<size_t> visited; + for (std::optional<size_t> cur = i; cur; cur = parent_index[*cur]) { + if (!visited.insert(*cur).second) { + return base::ErrStatus( + "PROFILE_FROM_TREE: cycle detected at id %" PRId64, + nodes_[*cur].id); + } + locs.Append(static_cast<uint64_t>(*cur + 1)); + } + protozero::PackedVarInt vals; + vals.Append(n.self_value); + + auto* sample = profile->add_sample(); + sample->set_location_id(locs); + sample->set_value(vals); + } + + for (const auto& s : string_table_) { + profile->add_string_table(s); + } + + std::string out = profile.SerializeAsString(); + sqlite::result::TransientBytes(ctx, out.data(), + static_cast<int>(out.size())); + return base::OkStatus(); + } + + std::vector<Node> nodes_; + std::unordered_map<int64_t, size_t> id_to_index_; + std::vector<std::string> string_table_; + std::unordered_map<std::string, int64_t> string_index_; + std::string sample_type_; + std::string unit_; +}; + +base::Status TreeStepStatus(sqlite3_context* ctx, + size_t argc, + sqlite3_value** argv) { + auto** agg_context_ptr = static_cast<TreeAggregateContext**>( + sqlite3_aggregate_context(ctx, sizeof(TreeAggregateContext*))); + if (!agg_context_ptr) { + return base::ErrStatus("Failed to allocate aggregate context"); + } + if (!*agg_context_ptr) { + *agg_context_ptr = new TreeAggregateContext(); + } + return (*agg_context_ptr)->Step(argc, argv); +} + +struct ProfileFromTree { + static constexpr char kName[] = "PROFILE_FROM_TREE"; + static constexpr int kArgCount = 6; + using UserData = TraceProcessorContext; + + static void Step(sqlite3_context* ctx, int argc, sqlite3_value** argv) { + PERFETTO_CHECK(argc >= 0); + base::Status status = TreeStepStatus(ctx, static_cast<size_t>(argc), argv); + if (!status.ok()) { + sqlite::utils::SetError(ctx, kName, status); + } + } + + static void Final(sqlite3_context* ctx) { + auto** agg_context_ptr = + static_cast<TreeAggregateContext**>(sqlite3_aggregate_context(ctx, 0)); + if (!agg_context_ptr) { + return; + } + (*agg_context_ptr)->Final(ctx); + delete (*agg_context_ptr); + } +}; + } // namespace namespace pprof_functions { @@ -215,6 +505,7 @@ PerfettoSqlConnection*, std::vector<AggregateFunctionRegistration>& out) override { out.push_back(MakeAggregateRegistration<ProfileBuilder>(trace_context_)); + out.push_back(MakeAggregateRegistration<ProfileFromTree>(trace_context_)); } };
diff --git a/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py b/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py index 032849e..3a7cb47 100644 --- a/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py +++ b/test/trace_processor/diff_tests/stdlib/prelude/pprof_functions_tests.py
@@ -196,6 +196,70 @@ C (0x0) """)) + def test_profile_from_tree(self): + return DiffTestBlueprint( + trace=DataPath("perf_sample.pb"), + query=""" + WITH t(id, parent_id, name, value) AS ( + VALUES + (1, NULL, 'main', 0), + (2, 1, 'foo', 100), + (3, 1, 'bar', 50), + (4, 2, 'baz', 25) + ) + SELECT HEX(profile_from_tree( + id, parent_id, name, value, 'wall', 'nanoseconds')) + FROM t + """, + out=BinaryProto( + message_type="perfetto.third_party.perftools.profiles.Profile", + post_processing=PrintProfileProto, + contents=""" + Sample: + Values: 100 + Stack: + foo (0x0) + main (0x0) + + Sample: + Values: 25 + Stack: + baz (0x0) + foo (0x0) + main (0x0) + + Sample: + Values: 50 + Stack: + bar (0x0) + main (0x0) + """)) + + def test_pprof_from_tree_macro(self): + return DiffTestBlueprint( + trace=DataPath("perf_sample.pb"), + query=""" + INCLUDE PERFETTO MODULE pprof.from_tree; + WITH t(id, parent_id, name, value) AS ( + VALUES + (1, NULL, 'root', 0), + (2, 1, 'leaf', 7) + ) + SELECT HEX(_pprof_from_tree!( + _tree_from_table!(t, (name, value)), + name, value, 'samples', 'count')) + """, + out=BinaryProto( + message_type="perfetto.third_party.perftools.profiles.Profile", + post_processing=PrintProfileProto, + contents=""" + Sample: + Values: 7 + Stack: + leaf (0x0) + root (0x0) + """)) + def test_annotated_callstack(self): return DiffTestBlueprint( trace=DataPath("perf_sample_annotations.pftrace"),
diff --git a/ui/src/assets/widgets/flamegraph.scss b/ui/src/assets/widgets/flamegraph.scss index fc6db29..6ce2aad 100644 --- a/ui/src/assets/widgets/flamegraph.scss +++ b/ui/src/assets/widgets/flamegraph.scss
@@ -72,6 +72,10 @@ align-self: center; } + .pf-flamegraph-filter-bar-spacer { + flex: 0 0 8px; + } + .pf-flamegraph-filter-label { font-weight: 500; flex-shrink: 0;
diff --git a/ui/src/components/details/sql_table_tab.ts b/ui/src/components/details/sql_table_tab.ts index 13dabd9..aadea91 100644 --- a/ui/src/components/details/sql_table_tab.ts +++ b/ui/src/components/details/sql_table_tab.ts
@@ -20,6 +20,7 @@ import {DetailsShell} from '../../widgets/details_shell'; import {Popup, PopupPosition} from '../../widgets/popup'; import {AddDebugTrackMenu} from '../tracks/add_debug_track_menu'; +import {AddFlamegraphMenu} from '../tracks/add_flamegraph_menu'; import {getSelectableColumns, SqlTableState} from '../widgets/sql/table/state'; import {SqlTable} from '../widgets/sql/table/table'; import type {SqlTableDefinition} from '../widgets/sql/table/table_description'; @@ -126,6 +127,7 @@ const debugTrackColumns = Object.values(columns).filter( (c) => !c.startsWith('__'), ); + const sourceQuery = `SELECT ${debugTrackColumns.join(', ')} FROM (${selectStatement})`; const addDebugTrack = m( Popup, { @@ -134,13 +136,26 @@ }, m(AddDebugTrackMenu, { trace: this.tableState.trace, - query: `SELECT ${debugTrackColumns.join(', ')} FROM (${selectStatement})`, + query: sourceQuery, + availableColumns: debugTrackColumns, + }), + ); + const addFlamegraph = m( + Popup, + { + trigger: m(Button, {label: 'Add flamegraph'}), + position: PopupPosition.Top, + }, + m(AddFlamegraphMenu, { + trace: this.tableState.trace, + query: sourceQuery, availableColumns: debugTrackColumns, }), ); return [ ...navigation, addDebugTrack, + addFlamegraph, m( PopupMenu, {
diff --git a/ui/src/components/query_flamegraph.ts b/ui/src/components/query_flamegraph.ts index e891113..8bfb29e 100644 --- a/ui/src/components/query_flamegraph.ts +++ b/ui/src/components/query_flamegraph.ts
@@ -16,6 +16,7 @@ import {AsyncLimiter} from '../base/async_limiter'; import {AsyncDisposableStack} from '../base/disposable_stack'; import {assertExists} from '../base/assert'; +import {download} from '../base/download_utils'; import {uuidv4Sql} from '../base/uuid'; import type {Engine} from '../trace_processor/engine'; import { @@ -23,6 +24,7 @@ createPerfettoTable, } from '../trace_processor/sql_utils'; import { + BLOB, NUM, NUM_NULL, STR, @@ -221,6 +223,52 @@ filters: [], }, onStateChange, + onDownloadPprof: + metrics === undefined + ? undefined + : (metricName) => this.downloadPprof(metrics, metricName), + }); + } + + // Serializes the unfiltered tree behind `metric` as a pprof Profile by + // delegating to the trace_processor `_pprof_from_tree!` aggregate, then + // triggers a browser download. Filters applied in the UI do not affect + // the exported profile. + private async downloadPprof( + metrics: ReadonlyArray<QueryFlamegraphMetric>, + metricName: string, + ): Promise<void> { + const metric = metrics.find((x) => x.name === metricName); + if (metric === undefined) return; + const engine = this.trace.engine; + if (metric.dependencySql !== undefined) { + await engine.query(metric.dependencySql); + } + await engine.query('include perfetto module pprof.from_tree;'); + + const uuid = uuidv4Sql(); + await using disposable = new AsyncDisposableStack(); + disposable.use( + await createPerfettoTable({ + engine, + name: `_pprof_source_${uuid}`, + as: metric.statement, + }), + ); + const result = await engine.query(` + select profile_from_tree( + id, parentId, name, value, + ${sqliteString(metric.name)}, ${sqliteString(metric.unit)} + ) as bytes + from _pprof_source_${uuid} + `); + const it = result.iter({bytes: BLOB}); + if (!it.valid()) return; + const safeName = metric.name.replace(/[^A-Za-z0-9._-]+/g, '_'); + download({ + content: it.bytes, + fileName: `${safeName || 'flamegraph'}.pb`, + mimeType: 'application/octet-stream', }); }
diff --git a/ui/src/components/tracks/add_flamegraph_menu.ts b/ui/src/components/tracks/add_flamegraph_menu.ts new file mode 100644 index 0000000..18e70c8 --- /dev/null +++ b/ui/src/components/tracks/add_flamegraph_menu.ts
@@ -0,0 +1,229 @@ +// Copyright (C) 2026 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 {findRef} from '../../base/dom_utils'; +import type {Trace} from '../../public/trace'; +import {Form, FormGrid, FormLabel, FormSection} from '../../widgets/form'; +import {Select} from '../../widgets/select'; +import {TextInput} from '../../widgets/text_input'; +import {addQueryFlamegraphTab} from './query_flamegraph_tab'; + +interface AddFlamegraphMenuAttrs { + readonly trace: Trace; // Required for opening the flamegraph tab. + // A list of available columns in the query results - used to work out sensible defaults for each field. + readonly availableColumns: ReadonlyArray<string>; + // The actual query that produces the rows. + readonly query: string; + + // Called when the user opens the flamegraph. + readonly onAdd?: () => void; +} + +const TITLE_FIELD_REF = 'FLAMEGRAPH_TITLE_FIELD'; + +function chooseDefaultColumn( + columns: ReadonlyArray<string>, + name: string, +): string | undefined { + // Search for exact match. + const exactMatch = columns.find((col) => col === name); + if (exactMatch) return exactMatch; + + // Search for partial match (handles `<table>_id`-style aliases). + const partialMatch = columns.find((col) => col.endsWith(`_${name}`)); + if (partialMatch) return partialMatch; + + return undefined; +} + +interface ConfigurationOptions { + id: string; + parentId: string; + name: string; + value: string; +} + +export class AddFlamegraphMenu + implements m.ClassComponent<AddFlamegraphMenuAttrs> +{ + private title = ''; + private sampleType = 'samples'; + private unit = 'count'; + private readonly options: Partial<ConfigurationOptions>; + + constructor({attrs}: m.Vnode<AddFlamegraphMenuAttrs>) { + const columns = attrs.availableColumns; + + // Initialize the settings to some sensible defaults. + this.options = { + id: chooseDefaultColumn(columns, 'id'), + parentId: chooseDefaultColumn(columns, 'parent_id'), + name: chooseDefaultColumn(columns, 'name'), + value: + chooseDefaultColumn(columns, 'self_value') ?? + chooseDefaultColumn(columns, 'value') ?? + chooseDefaultColumn(columns, 'self_size') ?? + chooseDefaultColumn(columns, 'self_count') ?? + chooseDefaultColumn(columns, 'dur'), + }; + } + + oncreate({dom}: m.VnodeDOM<AddFlamegraphMenuAttrs>) { + this.focusTitleField(dom); + } + + private focusTitleField(dom: Element) { + const element = findRef(dom, TITLE_FIELD_REF); + if (element) { + if (element instanceof HTMLInputElement) { + element.focus(); + } + } + } + + view({attrs}: m.Vnode<AddFlamegraphMenuAttrs>) { + return m( + Form, + { + className: 'pf-add-flamegraph-menu', + onSubmit: () => { + attrs.onAdd?.(); + this.openFlamegraph(attrs); + }, + submitLabel: 'Add Flamegraph', + cancelLabel: 'Cancel', + }, + m(FormLabel, {for: 'flamegraph_title'}, 'Title'), + m( + TextInput, + { + id: 'flamegraph_title', + ref: TITLE_FIELD_REF, + onkeydown: (e: KeyboardEvent) => { + // Allow Esc to close popup. + if (e.key === 'Escape') return; + }, + oninput: (e: InputEvent) => { + if (!e.target) return; + this.title = (e.target as HTMLInputElement).value; + }, + placeholder: 'Enter flamegraph title...', + }, + this.title, + ), + m( + FormSection, + {label: 'Column mapping'}, + m( + FormGrid, + this.renderFormSelectInput('Id *', 'id', attrs.availableColumns), + this.renderFormSelectInput( + 'Parent id *', + 'parentId', + attrs.availableColumns, + ), + this.renderFormSelectInput('Name *', 'name', attrs.availableColumns), + this.renderFormSelectInput( + 'Self value *', + 'value', + attrs.availableColumns, + ), + m(FormLabel, {for: 'flamegraph_sample_type'}, 'Sample type'), + m(TextInput, { + id: 'flamegraph_sample_type', + value: this.sampleType, + oninput: (e: InputEvent) => { + this.sampleType = (e.target as HTMLInputElement).value; + }, + }), + m(FormLabel, {for: 'flamegraph_unit'}, 'Unit'), + m(TextInput, { + id: 'flamegraph_unit', + value: this.unit, + oninput: (e: InputEvent) => { + this.unit = (e.target as HTMLInputElement).value; + }, + }), + ), + ), + ); + } + + private renderFormSelectInput<K extends keyof ConfigurationOptions>( + label: m.Children, + optionKey: K, + options: ReadonlyArray<string>, + ) { + return [ + m(FormLabel, {for: optionKey}, label), + m( + Select, + { + id: optionKey, + required: true, + oninput: (e: Event) => { + if (!e.target) return; + const newValue = (e.target as HTMLSelectElement).value; + if (newValue === '') { + delete this.options[optionKey]; + } else { + this.options[optionKey] = newValue; + } + }, + }, + m( + 'option', + { + selected: this.options[optionKey] === undefined, + value: '', + hidden: true, + disabled: true, + }, + 'Select a column...', + ), + options.map((opt) => + m( + 'option', + {selected: this.options[optionKey] === opt, value: opt}, + opt, + ), + ), + ), + ]; + } + + private openFlamegraph(attrs: AddFlamegraphMenuAttrs) { + const {id, parentId, name, value} = this.options; + if ( + id === undefined || + parentId === undefined || + name === undefined || + value === undefined + ) { + return; + } + addQueryFlamegraphTab({ + trace: attrs.trace, + title: this.title.trim() || 'flamegraph', + sourceQuery: attrs.query, + idColumn: id, + parentIdColumn: parentId, + nameColumn: name, + valueColumn: value, + sampleType: this.sampleType.trim() || 'samples', + unit: this.unit.trim() || 'count', + }); + } +}
diff --git a/ui/src/components/tracks/query_flamegraph_tab.ts b/ui/src/components/tracks/query_flamegraph_tab.ts new file mode 100644 index 0000000..a35e6ee --- /dev/null +++ b/ui/src/components/tracks/query_flamegraph_tab.ts
@@ -0,0 +1,93 @@ +// Copyright (C) 2026 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 type {Trace} from '../../public/trace'; +import {metricsFromTableOrSubquery, QueryFlamegraph} from '../query_flamegraph'; +import { + FLAMEGRAPH_STATE_SCHEMA, + type FlamegraphState, +} from '../../widgets/flamegraph'; + +export interface AddQueryFlamegraphArgs { + readonly trace: Trace; + readonly title: string; + // SQL subquery exposing the user's data. Treated as a TableOrSubquery. + readonly sourceQuery: string; + readonly idColumn: string; + readonly parentIdColumn: string; + readonly nameColumn: string; + readonly valueColumn: string; + // Metric metadata - shows up in pprof and in the flamegraph header. + readonly sampleType: string; + readonly unit: string; +} + +// Opens an ephemeral Flamegraph tab driven by a user-supplied SQL query. +// The tab reuses the same `QueryFlamegraph` component used by the heap +// profile / java heap / slice flamegraph paths, so it inherits the +// shared "Download pprof" toolbar button. +export function addQueryFlamegraphTab(args: AddQueryFlamegraphArgs): void { + const { + trace, + title, + sourceQuery, + idColumn, + parentIdColumn, + nameColumn, + valueColumn, + sampleType, + unit, + } = args; + + // The flamegraph contract expects columns named exactly id, parentId, + // name and selfValue. Project the user's chosen columns onto that + // shape via a subquery. + const projectedSubquery = `( + select + ${idColumn} as id, + ${parentIdColumn} as parentId, + ${nameColumn} as name, + ${valueColumn} as selfValue + from (${sourceQuery}) + )`; + + const metrics = metricsFromTableOrSubquery({ + tableMetrics: [{name: sampleType, unit, columnName: 'selfValue'}], + tableOrSubquery: projectedSubquery, + }); + + const flamegraph = new QueryFlamegraph(trace); + let state: FlamegraphState = FLAMEGRAPH_STATE_SCHEMA.parse({ + selectedMetricName: sampleType, + filters: [], + view: {kind: 'TOP_DOWN'}, + }); + const uri = `query_flamegraph#${title}-${Date.now()}`; + trace.tabs.registerTab({ + uri, + isEphemeral: true, + content: { + getTitle: () => title, + render: () => + flamegraph.render({ + metrics, + state, + onStateChange: (next: FlamegraphState) => { + state = next; + }, + }), + }, + }); + trace.tabs.showTab(uri); +}
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts b/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts index d63f4a8..8a38f18 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts +++ b/ui/src/plugins/dev.perfetto.QueryPage/results_table.ts
@@ -16,6 +16,7 @@ import {classNames} from '../../base/classnames'; import {Icons} from '../../base/semantic_icons'; import {AddDebugTrackMenu} from '../../components/tracks/add_debug_track_menu'; +import {AddFlamegraphMenu} from '../../components/tracks/add_flamegraph_menu'; import type {DataSource} from '../../components/widgets/datagrid/data_source'; import {DataGrid, renderCell} from '../../components/widgets/datagrid/datagrid'; import type { @@ -198,6 +199,22 @@ }), ); + const flamegraphButton = m( + Popup, + { + trigger: m(Button, { + label: 'Add flamegraph', + icon: 'local_fire_department', + }), + position: PopupPosition.Top, + }, + m(AddFlamegraphMenu, { + trace: attrs.trace, + query: data.lastStatementSql, + availableColumns: data.columns, + }), + ); + const multiStatementWarning = data.statementWithOutputCount > 1 && m( @@ -219,7 +236,7 @@ fillHeight: true, emptyStateMessage: 'Query returned no rows', toolbarItemsLeft: toolbarLeft, - toolbarItemsRight: [linkingButton, debugTrackButton], + toolbarItemsRight: [linkingButton, debugTrackButton, flamegraphButton], showExportButton: true, }), ];
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts index 73b83ac..50a38a2 100644 --- a/ui/src/widgets/flamegraph.ts +++ b/ui/src/widgets/flamegraph.ts
@@ -195,6 +195,12 @@ readonly data: FlamegraphQueryData | undefined; readonly onStateChange: (filters: FlamegraphState) => void; + + // Optional handler for the toolbar "Download as pprof" button. When + // omitted the button is hidden; this keeps the widget free of any + // engine/IO dependencies and lets the engine-aware wrapper + // (`QueryFlamegraph`) own the SQL pipeline. + readonly onDownloadPprof?: (metricName: string) => void | Promise<void>; } type FilterType = @@ -927,6 +933,17 @@ m(RadioGroup.Button, {value: 'bottom-up'}, 'Bottom Up'), ], ), + attrs.onDownloadPprof !== undefined && [ + m('.pf-flamegraph-filter-bar-spacer'), + m(Button, { + icon: 'download', + compact: true, + title: 'Download as pprof', + disabled: attrs.data === undefined || attrs.data.nodes.length === 0, + onclick: () => + attrs.onDownloadPprof?.(attrs.state.selectedMetricName), + }), + ], ); }