Merge "ui: Include one or more perf tracks in area selection." am: d1a0f4435c

Original change: https://android-review.googlesource.com/c/platform/external/perfetto/+/1849998

Change-Id: If974595146725a659845e8f95728995f9540db60
diff --git a/src/trace_processor/importers/proto/flamegraph_construction_algorithms.cc b/src/trace_processor/importers/proto/flamegraph_construction_algorithms.cc
index 1579f4a..83dcc48 100644
--- a/src/trace_processor/importers/proto/flamegraph_construction_algorithms.cc
+++ b/src/trace_processor/importers/proto/flamegraph_construction_algorithms.cc
@@ -93,7 +93,7 @@
     TraceStorage* storage,
     base::Optional<UniquePid> upid,
     base::Optional<std::string> upid_group,
-    base::Optional<int64_t> timestamp,
+    int64_t default_timestamp,
     StringId profile_type) {
   const tables::StackProfileCallsiteTable& callsites_tbl =
       storage->stack_profile_callsite_table();
@@ -136,18 +136,16 @@
         } else {
           row.depth = 0;
         }
-        // For heap profiling, the 'ts' column is always the arbitrary value
-        // inputed in a query of the form below, not the actual time
-        // when the allocation happened:
+
+        // The 'ts' column is given a default value, taken from the query.
+        // So if the query is:
         // `select * form experimental_flamegraph(605908369259172, 1, 'native')`
-        // However, removing this value would break the query with constraints
-        // such as the one above because SQLite will do an equality check on the
-        // `ts` column: `ts == 605908369259172`.
-        // TODO(octaviant) find a way of removing this or giving it a meaningful
-        // value
-        if (timestamp) {
-          row.ts = *timestamp;
-        }
+        // then ts == 605908369259172
+        // This is not accurate. However, at present there is no other
+        // straightforward way of assigning timestamps to non-leaf nodes in the
+        // flamegraph tree. Non-leaf nodes would have to be assigned >= 1
+        // timestamps, which would increase data size without an advantage.
+        row.ts = default_timestamp;
         if (upid) {
           row.upid = *upid;
         }
@@ -399,14 +397,33 @@
         SqlValue::Long(tc.value)};
     filtered = filtered.Filter({cs});
   }
-
   if (filtered.row_count() == 0) {
-    return nullptr;
+    std::unique_ptr<tables::ExperimentalFlamegraphNodesTable> empty_tbl(
+        new tables::ExperimentalFlamegraphNodesTable(
+            storage->mutable_string_pool(), nullptr));
+    return empty_tbl;
+  }
+
+  // The logic underneath is selecting a default timestamp to be used by all
+  // frames which do not have a timestamp. The timestamp is taken from the query
+  // value and it's not meaningful for the row. It prevents however the rows
+  // with no timestamp from being filtered out by Sqlite, after we create the
+  // table ExperimentalFlamegraphNodesTable in this class.
+  int64_t default_timestamp = 0;
+  if (!time_constraints.empty()) {
+    auto& tc = time_constraints[0];
+    if (tc.op == FilterOp::kGt) {
+      default_timestamp = tc.value + 1;
+    } else if (tc.op == FilterOp::kLt) {
+      default_timestamp = tc.value - 1;
+    } else {
+      default_timestamp = tc.value;
+    }
   }
   StringId profile_type = storage->InternString("perf");
   FlamegraphTableAndMergedCallsites table_and_callsites =
       BuildFlamegraphTableTreeStructure(storage, upid, upid_group,
-                                        base::nullopt, profile_type);
+                                        default_timestamp, profile_type);
   return BuildFlamegraphTableCallstackSizeAndCount(
       std::move(table_and_callsites.tbl),
       table_and_callsites.callsite_to_merged_callsite, filtered);
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 7c42009..8ab5034 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -60,6 +60,7 @@
   TrackState,
   VisibleState,
 } from './state';
+import {toNs} from './time';
 
 type StateDraft = Draft<State>;
 
@@ -616,8 +617,13 @@
       ts: args.ts,
       type: args.type,
     };
-    this.openFlamegraph(
-        state, {...args, viewingOption: DEFAULT_VIEWING_OPTION});
+    this.openFlamegraph(state, {
+      type: args.type,
+      startNs: toNs(state.traceTime.startSec),
+      endNs: args.ts,
+      upids: [args.upid],
+      viewingOption: DEFAULT_VIEWING_OPTION
+    });
   },
 
   selectPerfSamples(
@@ -630,21 +636,27 @@
       ts: args.ts,
       type: args.type,
     };
-    this.openFlamegraph(state, {...args, viewingOption: PERF_SAMPLES_KEY});
+    this.openFlamegraph(state, {
+      type: args.type,
+      startNs: toNs(state.traceTime.startSec),
+      endNs: args.ts,
+      upids: [args.upid],
+      viewingOption: PERF_SAMPLES_KEY
+    });
   },
 
   openFlamegraph(state: StateDraft, args: {
-    id: number,
-    upid: number,
-    ts: number,
+    upids: number[],
+    startNs: number,
+    endNs: number,
     type: string,
     viewingOption: FlamegraphStateViewingOption
   }): void {
     state.currentFlamegraphState = {
       kind: 'FLAMEGRAPH_STATE',
-      id: args.id,
-      upid: args.upid,
-      ts: args.ts,
+      upids: args.upids,
+      startNs: args.startNs,
+      endNs: args.endNs,
       type: args.type,
       viewingOption: args.viewingOption,
       focusRegex: ''
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 9ceff3f..865982f 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -72,7 +72,8 @@
 // 11: Rename updateChromeCategories to fetchChromeCategories.
 // 12: Add a field to cache mapping from UI track ID to trace track ID in order
 //     to speed up flow arrows rendering.
-export const STATE_VERSION = 12;
+// 13: FlamegraphState changed to support area selection.
+export const STATE_VERSION = 13;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
 
@@ -244,9 +245,9 @@
 
 export interface FlamegraphState {
   kind: 'FLAMEGRAPH_STATE';
-  id: number;
-  upid: number;
-  ts: number;
+  upids: number[];
+  startNs: number;
+  endNs: number;
   type: string;
   viewingOption: FlamegraphStateViewingOption;
   focusRegex: string;
diff --git a/ui/src/controller/aggregation/aggregation_controller.ts b/ui/src/controller/aggregation/aggregation_controller.ts
index 06c3cee..af41ec7 100644
--- a/ui/src/controller/aggregation/aggregation_controller.ts
+++ b/ui/src/controller/aggregation/aggregation_controller.ts
@@ -26,6 +26,7 @@
 import {NUM} from '../../common/query_result';
 import {Area, Sorting} from '../../common/state';
 import {publishAggregateData} from '../../frontend/publish';
+import {AreaSelectionHandler} from '../area_selection_handler';
 import {Controller} from '../controller';
 import {globals} from '../globals';
 
@@ -38,17 +39,9 @@
   return column.kind === 'STRING' || column.kind === 'STATE';
 }
 
-function isAreaEqual(area: Area, previousArea?: Area) {
-  if (previousArea === undefined || area.startSec !== previousArea.startSec ||
-      area.endSec !== previousArea.endSec) {
-    return false;
-  }
-  return area.tracks.every((element, i) => element === previousArea.tracks[i]);
-}
-
 export abstract class AggregationController extends Controller<'main'> {
   readonly kind: string;
-  private previousArea?: Area;
+  private areaSelectionHandler: AreaSelectionHandler;
   private previousSorting?: Sorting;
   private requestingData = false;
   private queuedRequest = false;
@@ -64,6 +57,7 @@
   constructor(private args: AggregationControllerArgs) {
     super('main');
     this.kind = this.args.kind;
+    this.areaSelectionHandler = new AreaSelectionHandler();
   }
 
   run() {
@@ -82,22 +76,20 @@
       });
       return;
     }
-    const selectedArea = globals.state.areas[selection.areaId];
     const aggregatePreferences =
         globals.state.aggregatePreferences[this.args.kind];
 
-    const areaChanged = !isAreaEqual(selectedArea, this.previousArea);
     const sortingChanged = aggregatePreferences &&
         this.previousSorting !== aggregatePreferences.sorting;
-    if (!areaChanged && !sortingChanged) return;
+    const [hasAreaChanged, area] = this.areaSelectionHandler.getAreaChange();
+    if ((!hasAreaChanged && !sortingChanged) || !area) return;
 
     if (this.requestingData) {
       this.queuedRequest = true;
     } else {
       this.requestingData = true;
       if (sortingChanged) this.previousSorting = aggregatePreferences.sorting;
-      if (areaChanged) this.previousArea = Object.assign({}, selectedArea);
-      this.getAggregateData(selectedArea, areaChanged)
+      this.getAggregateData(area, hasAreaChanged)
           .then(data => publishAggregateData({data, kind: this.args.kind}))
           .finally(() => {
             this.requestingData = false;
diff --git a/ui/src/controller/area_selection_handler.ts b/ui/src/controller/area_selection_handler.ts
new file mode 100644
index 0000000..3f5368d
--- /dev/null
+++ b/ui/src/controller/area_selection_handler.ts
@@ -0,0 +1,57 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Area, AreaById} from '../common/state';
+import {globals as frontendGlobals} from '../frontend/globals';
+
+export class AreaSelectionHandler {
+  private previousArea?: Area;
+
+  getAreaChange(): [boolean, AreaById|undefined] {
+    const currentSelection = frontendGlobals.state.currentSelection;
+    if (currentSelection === null || currentSelection.kind !== 'AREA') {
+      return [false, undefined];
+    }
+
+    const selectedArea = frontendGlobals.state.areas[currentSelection.areaId];
+    // Area is considered changed if:
+    // 1. The new area is defined and the old area undefined.
+    // 2. The new area is undefined and the old area defined (viceversa from 1).
+    // 3. Both areas are defined but their start or end times differ.
+    // 4. Both areas are defined but their tracks differ.
+    let hasAreaChanged = (!!this.previousArea !== !!selectedArea);
+    if (selectedArea && this.previousArea) {
+      // There seems to be an issue with clang-format http://shortn/_Pt98d5MCjG
+      // where `a ||= b` is formatted to `a || = b`, by inserting a space which
+      // breaks the operator.
+      // Therefore, we are using the pattern `a = a || b` instead.
+      hasAreaChanged = hasAreaChanged ||
+          selectedArea.startSec !== this.previousArea.startSec;
+      hasAreaChanged =
+          hasAreaChanged || selectedArea.endSec !== this.previousArea.endSec;
+      hasAreaChanged = hasAreaChanged ||
+          selectedArea.tracks.length !== this.previousArea.tracks.length;
+      for (let i = 0; i < selectedArea.tracks.length; ++i) {
+        hasAreaChanged = hasAreaChanged ||
+            selectedArea.tracks[i] !== this.previousArea.tracks[i];
+      }
+    }
+
+    if (hasAreaChanged) {
+      this.previousArea = selectedArea;
+    }
+
+    return [hasAreaChanged, selectedArea];
+  }
+}
diff --git a/ui/src/controller/area_selection_handler_unittest.ts b/ui/src/controller/area_selection_handler_unittest.ts
new file mode 100644
index 0000000..563e816
--- /dev/null
+++ b/ui/src/controller/area_selection_handler_unittest.ts
@@ -0,0 +1,135 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {AreaById, createEmptyState} from '../common/state';
+import {globals as frontendGlobals} from '../frontend/globals';
+
+import {AreaSelectionHandler} from './area_selection_handler';
+
+test('validAreaAfterUndefinedArea', () => {
+  const areaId = '0';
+  const latestArea: AreaById = {startSec: 0, endSec: 1, tracks: [], id: areaId};
+  frontendGlobals.state = createEmptyState();
+  frontendGlobals.state.currentSelection = {kind: 'AREA', areaId};
+  frontendGlobals.state.areas[areaId] = latestArea;
+
+  const areaSelectionHandler = new AreaSelectionHandler();
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(true);
+  expect(selectedArea).toEqual(latestArea);
+});
+
+test('UndefinedAreaAfterValidArea', () => {
+  const previousAreaId = '0';
+  const previous:
+      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+  frontendGlobals.state = createEmptyState();
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: previousAreaId
+  };
+  frontendGlobals.state.areas[previousAreaId] = previous;
+  const areaSelectionHandler = new AreaSelectionHandler();
+  areaSelectionHandler.getAreaChange();
+
+  const currentAreaId = '1';
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: currentAreaId
+  };
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(true);
+  expect(selectedArea).toEqual(undefined);
+});
+
+test('UndefinedAreaAfterUndefinedArea', () => {
+  frontendGlobals.state.currentSelection = {kind: 'AREA', areaId: '0'};
+  const areaSelectionHandler = new AreaSelectionHandler();
+  areaSelectionHandler.getAreaChange();
+
+  frontendGlobals.state.currentSelection = {kind: 'AREA', areaId: '1'};
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(true);
+  expect(selectedArea).toEqual(undefined);
+});
+
+test('validAreaAfterValidArea', () => {
+  const previousAreaId = '0';
+  const previous:
+      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+  frontendGlobals.state = createEmptyState();
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: previousAreaId
+  };
+  frontendGlobals.state.areas[previousAreaId] = previous;
+  const areaSelectionHandler = new AreaSelectionHandler();
+  areaSelectionHandler.getAreaChange();
+
+  const currentAreaId = '1';
+  const current:
+      AreaById = {startSec: 1, endSec: 2, tracks: [], id: currentAreaId};
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: currentAreaId
+  };
+  frontendGlobals.state.areas[currentAreaId] = current;
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(true);
+  expect(selectedArea).toEqual(current);
+});
+
+test('sameAreaSelected', () => {
+  const previousAreaId = '0';
+  const previous:
+      AreaById = {startSec: 0, endSec: 1, tracks: [], id: previousAreaId};
+  frontendGlobals.state = createEmptyState();
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: previousAreaId
+  };
+  frontendGlobals.state.areas[previousAreaId] = previous;
+  const areaSelectionHandler = new AreaSelectionHandler();
+  areaSelectionHandler.getAreaChange();
+
+  const currentAreaId = '0';
+  const current:
+      AreaById = {startSec: 0, endSec: 1, tracks: [], id: currentAreaId};
+  frontendGlobals.state.currentSelection = {
+    kind: 'AREA',
+    areaId: currentAreaId
+  };
+  frontendGlobals.state.areas[currentAreaId] = current;
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(false);
+  expect(selectedArea).toEqual(current);
+});
+
+test('NonAreaSelectionAfterUndefinedArea', () => {
+  frontendGlobals.state.currentSelection = {kind: 'AREA', areaId: '0'};
+  const areaSelectionHandler = new AreaSelectionHandler();
+  areaSelectionHandler.getAreaChange();
+
+  frontendGlobals.state
+      .currentSelection = {kind: 'COUNTER', leftTs: 0, rightTs: 0, id: 1};
+  const [hasAreaChanged, selectedArea] = areaSelectionHandler.getAreaChange();
+
+  expect(hasAreaChanged).toEqual(false);
+  expect(selectedArea).toEqual(undefined);
+});
diff --git a/ui/src/controller/flamegraph_controller.ts b/ui/src/controller/flamegraph_controller.ts
index 7459c3f..9fec1f5 100644
--- a/ui/src/controller/flamegraph_controller.ts
+++ b/ui/src/controller/flamegraph_controller.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {Actions} from '../common/actions';
 import {Engine} from '../common/engine';
 import {
   ALLOC_SPACE_MEMORY_ALLOCATED_KEY,
@@ -26,10 +27,18 @@
 } from '../common/flamegraph_util';
 import {NUM, STR} from '../common/query_result';
 import {CallsiteInfo, FlamegraphState} from '../common/state';
-import {fromNs} from '../common/time';
-import {FlamegraphDetails} from '../frontend/globals';
+import {toNs} from '../common/time';
+import {
+  FlamegraphDetails,
+  globals as frontendGlobals
+} from '../frontend/globals';
 import {publishFlamegraphDetails} from '../frontend/publish';
+import {
+  Config as PerfSampleConfig,
+  PERF_SAMPLES_PROFILE_TRACK_KIND
+} from '../tracks/perf_samples_profile/common';
 
+import {AreaSelectionHandler} from './area_selection_handler';
 import {Controller} from './controller';
 import {globals} from './globals';
 
@@ -78,15 +87,46 @@
   private requestingData = false;
   private queuedRequest = false;
   private flamegraphDetails: FlamegraphDetails = {};
+  private areaSelectionHandler: AreaSelectionHandler;
   private cache: TablesCache;
 
   constructor(private args: FlamegraphControllerArgs) {
     super('main');
     this.cache = new TablesCache(args.engine, 'grouped_callsites');
+    this.areaSelectionHandler = new AreaSelectionHandler();
   }
 
   run() {
-    const selection = globals.state.currentFlamegraphState;
+    const [hasAreaChanged, area] = this.areaSelectionHandler.getAreaChange();
+    if (hasAreaChanged) {
+      const upids = [];
+      if (!area) {
+        publishFlamegraphDetails(
+            {...frontendGlobals.flamegraphDetails, isInAreaSelection: false});
+        return;
+      }
+      for (const trackId of area.tracks) {
+        const trackState = frontendGlobals.state.tracks[trackId];
+        if (!trackState ||
+            trackState.kind !== PERF_SAMPLES_PROFILE_TRACK_KIND) {
+          continue;
+        }
+        upids.push((trackState.config as PerfSampleConfig).upid);
+      }
+      if (upids.length === 0) {
+        publishFlamegraphDetails(
+            {...frontendGlobals.flamegraphDetails, isInAreaSelection: false});
+        return;
+      }
+      frontendGlobals.dispatch(Actions.openFlamegraph({
+        upids,
+        startNs: toNs(area.startSec),
+        endNs: toNs(area.endSec),
+        type: 'perf',
+        viewingOption: PERF_SAMPLES_KEY
+      }));
+    }
+    const selection = frontendGlobals.state.currentFlamegraphState;
     if (!selection || !this.shouldRequestData(selection)) {
       return;
     }
@@ -96,15 +136,17 @@
     }
     this.requestingData = true;
 
-    this.assembleFlamegraphDetails(selection);
+    this.assembleFlamegraphDetails(selection, hasAreaChanged);
   }
 
-  private async assembleFlamegraphDetails(selection: FlamegraphState) {
+  private async assembleFlamegraphDetails(
+      selection: FlamegraphState, hasAreaChanged: boolean) {
     const selectedFlamegraphState = {...selection};
     const flamegraphMetadata = await this.getFlamegraphMetadata(
         selection.type,
-        selectedFlamegraphState.ts,
-        selectedFlamegraphState.upid);
+        selectedFlamegraphState.startNs,
+        selectedFlamegraphState.endNs,
+        selectedFlamegraphState.upids);
     if (flamegraphMetadata !== undefined) {
       Object.assign(this.flamegraphDetails, flamegraphMetadata);
     }
@@ -124,7 +166,8 @@
         undefined :
         selectedFlamegraphState.expandedCallsite.totalSize;
 
-    const key = `${selectedFlamegraphState.upid};${selectedFlamegraphState.ts}`;
+    const key = `${selectedFlamegraphState.upids};${
+        selectedFlamegraphState.startNs};${selectedFlamegraphState.endNs}`;
 
     try {
       const flamegraphData = await this.getFlamegraphData(
@@ -132,19 +175,21 @@
           selectedFlamegraphState.viewingOption ?
               selectedFlamegraphState.viewingOption :
               DEFAULT_VIEWING_OPTION,
-          selection.ts,
-          selectedFlamegraphState.upid,
+          selection.startNs,
+          selection.endNs,
+          selectedFlamegraphState.upids,
           selectedFlamegraphState.type,
           selectedFlamegraphState.focusRegex);
       if (flamegraphData !== undefined && selection &&
           selection.kind === selectedFlamegraphState.kind &&
-          selection.id === selectedFlamegraphState.id &&
-          selection.ts === selectedFlamegraphState.ts) {
+          selection.startNs === selectedFlamegraphState.startNs &&
+          selection.endNs === selectedFlamegraphState.endNs) {
         const expandedFlamegraphData =
             expandCallsites(flamegraphData, expandedId);
         this.prepareAndMergeCallsites(
             expandedFlamegraphData,
             this.lastSelectedFlamegraphState.viewingOption,
+            hasAreaChanged,
             rootSize,
             this.lastSelectedFlamegraphState.expandedCallsite);
       }
@@ -160,10 +205,11 @@
   private shouldRequestData(selection: FlamegraphState) {
     return selection.kind === 'FLAMEGRAPH_STATE' &&
         (this.lastSelectedFlamegraphState === undefined ||
-         (this.lastSelectedFlamegraphState.id !== selection.id ||
-          this.lastSelectedFlamegraphState.ts !== selection.ts ||
+         (this.lastSelectedFlamegraphState.startNs !== selection.startNs ||
+          this.lastSelectedFlamegraphState.endNs !== selection.endNs ||
           this.lastSelectedFlamegraphState.type !== selection.type ||
-          this.lastSelectedFlamegraphState.upid !== selection.upid ||
+          !FlamegraphController.areArraysEqual(
+              this.lastSelectedFlamegraphState.upids, selection.upids) ||
           this.lastSelectedFlamegraphState.viewingOption !==
               selection.viewingOption ||
           this.lastSelectedFlamegraphState.focusRegex !==
@@ -175,17 +221,20 @@
   private prepareAndMergeCallsites(
       flamegraphData: CallsiteInfo[],
       viewingOption: string|undefined = DEFAULT_VIEWING_OPTION,
-      rootSize?: number, expandedCallsite?: CallsiteInfo) {
+      hasAreaChanged: boolean, rootSize?: number,
+      expandedCallsite?: CallsiteInfo) {
     this.flamegraphDetails.flamegraph = mergeCallsites(
         flamegraphData, this.getMinSizeDisplayed(flamegraphData, rootSize));
     this.flamegraphDetails.expandedCallsite = expandedCallsite;
     this.flamegraphDetails.viewingOption = viewingOption;
+    this.flamegraphDetails.isInAreaSelection = hasAreaChanged;
     publishFlamegraphDetails(this.flamegraphDetails);
   }
 
   async getFlamegraphData(
-      baseKey: string, viewingOption: string, ts: number, upid: number,
-      type: string, focusRegex: string): Promise<CallsiteInfo[]> {
+      baseKey: string, viewingOption: string, startNs: number, endNs: number,
+      upids: number[], type: string,
+      focusRegex: string): Promise<CallsiteInfo[]> {
     let currentData: CallsiteInfo[];
     const key = `${baseKey}-${viewingOption}`;
     if (this.flamegraphDatasets.has(key)) {
@@ -196,8 +245,8 @@
       // Collecting data for drawing flamegraph for selected profile.
       // Data needs to be in following format:
       // id, name, parent_id, depth, total_size
-      const tableName =
-          await this.prepareViewsAndTables(ts, upid, type, focusRegex);
+      const tableName = await this.prepareViewsAndTables(
+          startNs, endNs, upids, type, focusRegex);
       currentData = await this.getFlamegraphDataFromTables(
           tableName, viewingOption, focusRegex);
       this.flamegraphDatasets.set(key, currentData);
@@ -329,7 +378,7 @@
   }
 
   private async prepareViewsAndTables(
-      ts: number, upid: number, type: string,
+      startNs: number, endNs: number, upids: number[], type: string,
       focusRegex: string): Promise<string> {
     // Creating unique names for views so we can reuse and not delete them
     // for each marker.
@@ -343,19 +392,25 @@
      * TODO(octaviant) this branching should be eliminated for simplicity.
      */
     if (type === 'perf') {
+      let upidConditional = `upid = ${upids[0]}`;
+      if (upids.length > 1) {
+        upidConditional =
+            `upid_group = '${FlamegraphController.serializeUpidGroup(upids)}'`;
+      }
       return this.cache.getTableName(
           `select id, name, map_name, parent_id, depth, cumulative_size,
           cumulative_alloc_size, cumulative_count, cumulative_alloc_count,
           size, alloc_size, count, alloc_count, source_file, line_number
           from experimental_flamegraph
-          where profile_type = "${type}" and ts <= ${ts} and upid = ${upid} 
+          where profile_type = '${type}' and ${startNs} <= ts and ts <= ${
+              endNs} and ${upidConditional}
           ${focusRegexConditional}`);
     }
     return this.cache.getTableName(
         `select id, name, map_name, parent_id, depth, cumulative_size,
           cumulative_alloc_size, cumulative_count, cumulative_alloc_count,
           size, alloc_size, count, alloc_count, source_file, line_number
-          from experimental_flamegraph(${ts}, ${upid}, '${type}') ${
+          from experimental_flamegraph(${endNs}, ${upids[0]}, '${type}') ${
             focusRegexConditional}`);
   }
 
@@ -371,20 +426,44 @@
     return MIN_PIXEL_DISPLAYED * rootSize / width;
   }
 
-  async getFlamegraphMetadata(type: string, ts: number, upid: number) {
+  async getFlamegraphMetadata(
+      type: string, startNs: number, endNs: number, upids: number[]) {
     // Don't do anything if selection of the marker stayed the same.
     if ((this.lastSelectedFlamegraphState !== undefined &&
-         ((this.lastSelectedFlamegraphState.ts === ts &&
-           this.lastSelectedFlamegraphState.upid === upid)))) {
+         ((this.lastSelectedFlamegraphState.startNs === startNs &&
+           this.lastSelectedFlamegraphState.endNs === endNs &&
+           FlamegraphController.areArraysEqual(
+               this.lastSelectedFlamegraphState.upids, upids))))) {
       return undefined;
     }
 
     // Collecting data for more information about profile, such as:
     // total memory allocated, memory that is allocated and not freed.
+    const upidGroup = FlamegraphController.serializeUpidGroup(upids);
+
     const result = await this.args.engine.query(
-        `select pid from process where upid = ${upid}`);
-    const pid = result.firstRow({pid: NUM}).pid;
-    const startTime = fromNs(ts) - globals.state.traceTime.startSec;
-    return {ts: startTime, tsNs: ts, pid, upid, type};
+        `select pid from process where upid in (${upidGroup})`);
+    const it = result.iter({pid: NUM});
+    const pids = [];
+    for (let i = 0; it.valid(); ++i, it.next()) {
+      pids.push(it.pid);
+    }
+    return {startNs, durNs: endNs - startNs, pids, upids, type};
+  }
+
+  private static areArraysEqual(a: number[], b: number[]) {
+    if (a.length !== b.length) {
+      return false;
+    }
+    for (let i = 0; i < a.length; i++) {
+      if (a[i] !== b[i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static serializeUpidGroup(upids: number[]) {
+    return new Array(upids).join();
   }
 }
diff --git a/ui/src/frontend/details_panel.ts b/ui/src/frontend/details_panel.ts
index 9f95ab3..5a98bd1 100644
--- a/ui/src/frontend/details_panel.ts
+++ b/ui/src/frontend/details_panel.ts
@@ -247,6 +247,13 @@
               })
             });
           }
+          if (globals.flamegraphDetails.isInAreaSelection) {
+            detailsPanels.push({
+              key: 'flamegraph_selection',
+              name: 'Flamegraph Selection',
+              vnode: m(FlamegraphDetailsPanel, {key: 'flamegraph'})
+            });
+          }
           break;
         case 'SLICE':
           detailsPanels.push({
diff --git a/ui/src/frontend/flamegraph_panel.ts b/ui/src/frontend/flamegraph_panel.ts
index c9817e0..35a4d9f 100644
--- a/ui/src/frontend/flamegraph_panel.ts
+++ b/ui/src/frontend/flamegraph_panel.ts
@@ -14,7 +14,7 @@
 
 import * as m from 'mithril';
 
-import {assertExists} from '../base/logging';
+import {assertExists, assertTrue} from '../base/logging';
 import {Actions} from '../common/actions';
 import {
   ALLOC_SPACE_MEMORY_ALLOCATED_KEY,
@@ -74,7 +74,7 @@
 export class FlamegraphDetailsPanel extends Panel<FlamegraphDetailsPanelAttrs> {
   private profileType?: ProfileType = undefined;
   private ts = 0;
-  private pid = 0;
+  private pids: number[] = [];
   private flamegraph: Flamegraph = new Flamegraph([]);
   private focusRegex = '';
   private updateFocusRegexDebounced = debounce(() => {
@@ -84,13 +84,13 @@
   view() {
     const flamegraphDetails = globals.flamegraphDetails;
     if (flamegraphDetails && flamegraphDetails.type !== undefined &&
-        flamegraphDetails.ts !== undefined &&
-        flamegraphDetails.tsNs !== undefined &&
-        flamegraphDetails.pid !== undefined &&
-        flamegraphDetails.upid !== undefined) {
+        flamegraphDetails.startNs !== undefined &&
+        flamegraphDetails.durNs !== undefined &&
+        flamegraphDetails.pids !== undefined &&
+        flamegraphDetails.upids !== undefined) {
       this.profileType = toProfileType(flamegraphDetails.type);
-      this.ts = flamegraphDetails.tsNs;
-      this.pid = flamegraphDetails.pid;
+      this.ts = flamegraphDetails.durNs;
+      this.pids = flamegraphDetails.pids;
       if (flamegraphDetails.flamegraph) {
         this.flamegraph.updateDataIfChanged(
             this.nodeRendering(), flamegraphDetails.flamegraph);
@@ -134,7 +134,7 @@
                         toSelectedCallsite(
                             flamegraphDetails.expandedCallsite)}`),
                   m('div.time',
-                    `Snapshot time: ${timeToCode(flamegraphDetails.ts)}`),
+                    `Snapshot time: ${timeToCode(flamegraphDetails.durNs)}`),
                   m('input[type=text][placeholder=Focus]', {
                     oninput: (e: Event) => {
                       const target = (e.target as HTMLInputElement);
@@ -216,7 +216,10 @@
     if (!engine) return;
     getCurrentTrace()
         .then(file => {
-          convertTraceToPprofAndDownload(file, this.pid, this.ts);
+          assertTrue(
+              this.pids.length === 1,
+              'Native profiles can only contain one pid.');
+          convertTraceToPprofAndDownload(file, this.pids[0], this.ts);
         })
         .catch(error => {
           throw new Error(`Failed to get current trace ${error}`);
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index c854da6..f730b1d 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -40,6 +40,7 @@
 type PivotTableHelperStore = Map<string, PivotTableHelper>;
 type AggregateDataStore = Map<string, AggregateData>;
 type Description = Map<string, string>;
+
 export interface SliceDetails {
   ts?: number;
   dur?: number;
@@ -104,14 +105,17 @@
 export interface FlamegraphDetails {
   type?: string;
   id?: number;
-  ts?: number;
-  tsNs?: number;
-  pid?: number;
-  upid?: number;
+  startNs?: number;
+  durNs?: number;
+  pids?: number[];
+  upids?: number[];
   flamegraph?: CallsiteInfo[];
   expandedCallsite?: CallsiteInfo;
   viewingOption?: string;
   expandedId?: number;
+  // isInAreaSelection is true if a flamegraph is part of the current area
+  // selection.
+  isInAreaSelection?: boolean;
 }
 
 export interface CpuProfileDetails {