ui: Create CounterTrack - a flexible new counter track impl

Change-Id: I9921bf3708148d8ca5ecfa6c60392921f02bcc55
diff --git a/ui/src/components/tracks/breakdown_tracks.ts b/ui/src/components/tracks/breakdown_tracks.ts
index 551b5a5..03d1543 100644
--- a/ui/src/components/tracks/breakdown_tracks.ts
+++ b/ui/src/components/tracks/breakdown_tracks.ts
@@ -15,7 +15,6 @@
 import {sqliteString} from '../../base/string_utils';
 import {uuidv4} from '../../base/uuid';
 import {SliceTrack} from './slice_track';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import {Trace} from '../../public/trace';
 import {TrackNode} from '../../public/workspace';
 import {SourceDataset} from '../../trace_processor/dataset';
@@ -25,7 +24,9 @@
   NUM_NULL,
   STR,
   LONG_NULL,
+  NUM,
 } from '../../trace_processor/query_result';
+import {CounterTrack} from './counter_track';
 import {TrackRenderer} from '../../public/track';
 
 /**
@@ -425,19 +426,19 @@
       name,
       newFilters,
       (uri: string, filtersClause: string) => {
-        return createQueryCounterTrack({
+        return CounterTrack.createMaterialized({
           trace: this.props.trace,
           uri,
-          data: {
-            sqlSource: `
+          dataset: new SourceDataset({
+            src: `
               SELECT ts, value FROM
               (${this.getAggregationQuery(filtersClause)})
             `,
-          },
-          columns: {
-            ts: 'ts',
-            value: 'value',
-          },
+            schema: {
+              ts: LONG,
+              value: NUM,
+            },
+          }),
         });
       },
       (filterClause) => this.getCounterTrackSortOrder(filterClause),
diff --git a/ui/src/components/tracks/counter_track.ts b/ui/src/components/tracks/counter_track.ts
new file mode 100644
index 0000000..96a87c8
--- /dev/null
+++ b/ui/src/components/tracks/counter_track.ts
@@ -0,0 +1,349 @@
+// Copyright (C) 2025 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 {Time} from '../../base/time';
+import {TrackEventDetailsPanel} from '../../public/details_panel';
+import {TrackEventDetails, TrackEventSelection} from '../../public/selection';
+import {Trace} from '../../public/trace';
+import {DatasetSchema, SourceDataset} from '../../trace_processor/dataset';
+import {SqlValue, LONG, NUM} from '../../trace_processor/query_result';
+import {createPerfettoTable} from '../../trace_processor/sql_utils';
+import {removeFalsyValues} from '../../base/array_utils';
+import {CounterTrackDetailsPanel} from './counter_track_details_panel';
+import {BaseCounterTrack, CounterOptions} from './base_counter_track';
+import {TrackMouseEvent} from '../../public/track';
+
+export interface CounterTrackAttrs<T extends DatasetSchema> {
+  /**
+   * The trace object used by the track for accessing the query engine and other
+   * trace-related resources.
+   */
+  readonly trace: Trace;
+
+  /**
+   * The URI of this track, which must match the URI specified in the track
+   * descriptor.
+   */
+  readonly uri: string;
+
+  /**
+   * The source dataset defining the content of this track.
+   *
+   * A source dataset consists of a SQL select statement or table name with a
+   * column schema and optional filtering information. It represents a set of
+   * instructions to extract counter-like rows from trace processor that
+   * represents the content of this track, which avoids the need to materialize
+   * all counter values into JavaScript beforehand. This approach minimizes
+   * memory usage and improves performance by only materializing the necessary
+   * rows on demand.
+   *
+   * Required columns:
+   * - `ts` (LONG): Timestamp of each counter value (in nanoseconds).
+   * - `value` (NUM): The counter value at the given timestamp.
+   *
+   * Auto-generated columns (if not provided):
+   * - `id` (NUM): Unique identifier for counter points in the track. If not
+   *   provided in the dataset, will be automatically generated using ROW_NUMBER()
+   *   ordered by timestamp.
+   */
+  readonly dataset: SourceDataset<T> | (() => SourceDataset<T>);
+
+  /**
+   * An optional root table name for the track's data source.
+   *
+   * This typically represents a well-known table name and serves as the root
+   * `id` namespace for the track. It is primarily used for resolving events
+   * with a combination of table name and `id`.
+   */
+  readonly rootTableName?: string;
+
+  /**
+   * Default counter options that will be merged with user preferences.
+   * These provide the initial configuration for y-axis display, scaling,
+   * and other counter-specific rendering options.
+   */
+  readonly defaultOptions?: Partial<CounterOptions>;
+
+  /**
+   * An optional callback to customize the details panel for counter points on
+   * this track. Called whenever a counter point is selected.
+   *
+   * If omitted, a default details panel will be created that displays all
+   * fields from the dataset with appropriate formatting for common counter
+   * properties (ts, value).
+   */
+  detailsPanel?(row: T): TrackEventDetailsPanel;
+
+  /**
+   * An optional function to override the tooltip content for each counter point.
+   * If omitted, a default counter tooltip will be used.
+   */
+  tooltip?(row: T, formattedValue: string): m.Children;
+
+  /**
+   * An optional function to define buttons which are displayed on the track
+   * shell. This function is called every Mithril render cycle.
+   */
+  shellButtons?(): m.Children;
+}
+
+export type CounterRowSchema = {
+  readonly id?: number;
+  readonly ts: bigint;
+  readonly value: number;
+} & DatasetSchema;
+
+function getDataset<T extends DatasetSchema>(
+  attrs: CounterTrackAttrs<T>,
+): SourceDataset<T> {
+  const dataset = attrs.dataset;
+  return typeof dataset === 'function' ? dataset() : dataset;
+}
+
+export class CounterTrack<T extends CounterRowSchema> extends BaseCounterTrack {
+  readonly rootTableName?: string;
+
+  /**
+   * Factory function to create a CounterTrack. This is purely an alias for new
+   * CounterTrack() but exists for symmetry with createMaterialized()
+   * below.
+   *
+   * @param attrs The track attributes
+   * @returns A fully initialized CounterTrack
+   */
+  static create<T extends CounterRowSchema>(
+    attrs: CounterTrackAttrs<T>,
+  ): CounterTrack<T> {
+    return new CounterTrack(attrs);
+  }
+
+  /**
+   * Async factory function to create a CounterTrack, first materializing
+   * the dataset into a perfetto table. This can be more efficient if for
+   * example the dataset is a complex query with multiple joins or window
+   * functions, so materializing it up front can improve rendering performance,
+   * for a one-time cost.
+   *
+   * However, it does have some downsides:
+   * - You're front loading the cost of materialization, which can slow down
+   *   trace load times.
+   * - It uses more memory, as the entire dataset is materialized in memory as a
+   *   new table.
+   * - It means that this dataset track has a new root source table, which makes
+   *   it impossible to combine with other tracks for the purposes of bulk
+   *   operations such as aggregations or search.
+   *
+   * @param attrs The track attributes
+   * @returns A fully initialized CounterTrack
+   */
+  static async createMaterialized<T extends CounterRowSchema>(
+    attrs: CounterTrackAttrs<T>,
+  ): Promise<CounterTrack<T>> {
+    const originalDataset = getDataset(attrs);
+    // Create materialized table from the render query - we might as well
+    // materialize the calculated columns that are missing from the source
+    // dataset while we're here as this will improve performance at runtime.
+    const materializedTable = await createPerfettoTable({
+      engine: attrs.trace.engine,
+      as: generateRenderQuery(originalDataset),
+    });
+
+    // Create a new dataset that queries the materialized table
+    const materializedDataset = new SourceDataset({
+      src: materializedTable.name,
+      schema: {
+        ...originalDataset.schema,
+
+        // We know we must have these columns now as they are injected in
+        // generateRenderQuery(), so we can add them to the schema to avoid the
+        // DST from adding them again.
+        id: NUM,
+        ts: LONG,
+        value: NUM,
+      },
+    });
+
+    return new CounterTrack({
+      ...attrs,
+      dataset: materializedDataset,
+    });
+  }
+
+  private constructor(private readonly attrs: CounterTrackAttrs<T>) {
+    super(attrs.trace, attrs.uri, attrs.defaultOptions ?? {});
+    this.rootTableName = attrs.rootTableName;
+  }
+
+  override getSqlSource(): string {
+    const dataset = getDataset(this.attrs);
+    return generateRenderQuery(dataset);
+  }
+
+  getDataset() {
+    return getDataset(this.attrs);
+  }
+
+  detailsPanel(sel: TrackEventSelection): TrackEventDetailsPanel | undefined {
+    if (this.attrs.detailsPanel) {
+      // This type assertion is required as a temporary patch while the
+      // specifics of selection details are being worked out. Eventually we will
+      // change the selection details to be purely based on dataset, but there
+      // are currently some use cases preventing us from doing so. For now, this
+      // type assertion is safe as we know we just returned the entire row from
+      // from getSelectionDetails() so we know it must at least implement the
+      // row's type `T`.
+      return this.attrs.detailsPanel(sel as unknown as T);
+    } else {
+      // Provide a default details panel that shows all dataset fields
+      const dataset = getDataset(this.attrs);
+      return new CounterTrackDetailsPanel(
+        this.trace,
+        dataset,
+        sel as unknown as T,
+        this.getSqlSource(), // Pass the SQL source for enhanced details
+      );
+    }
+  }
+
+  async getSelectionDetails(
+    id: number,
+  ): Promise<TrackEventDetails | undefined> {
+    const {trace} = this.attrs;
+    const dataset = getDataset(this.attrs);
+
+    // If our dataset already has an id column, we can use it directly,
+    // otherwise we need to generate one using row number.
+    const query = (function () {
+      if (dataset.implements({id: NUM})) {
+        return dataset.query();
+      } else {
+        return `
+          SELECT
+            ROW_NUMBER() OVER (ORDER BY ts) AS id,
+            *
+          FROM (${dataset.query()})
+        `;
+      }
+    })();
+
+    const result = await trace.engine.query(`
+      SELECT *
+      FROM (${query})
+      WHERE id = ${id}
+    `);
+
+    const row = result.iter(dataset.schema);
+    if (!row.valid()) return undefined;
+
+    // Pull the fields out from the results
+    const data: {[key: string]: SqlValue} = {};
+    for (const col of result.columns()) {
+      data[col] = row.get(col);
+    }
+
+    return {
+      ...data,
+      ts: Time.fromRaw(row.ts),
+    };
+  }
+
+  override getTrackShellButtons(): m.Children {
+    const baseButtons = super.getTrackShellButtons();
+    const customButtons = this.attrs.shellButtons?.();
+    return [baseButtons, customButtons];
+  }
+
+  override renderTooltip(): m.Children {
+    // Get the base counter tooltip from BaseCounterTrack
+    const baseTooltip = super.renderTooltip();
+
+    // For now, we'll use the base tooltip since we can't access private members
+    // Custom tooltip functionality would need to be implemented differently
+    // or the BaseCounterTrack would need to expose the necessary methods
+    return baseTooltip;
+  }
+
+  onMouseClick({x, timescale}: TrackMouseEvent): boolean {
+    const time = timescale.pxToHpTime(x).toTime('floor');
+    const dataset = getDataset(this.attrs);
+
+    // If our dataset already has an id column, we can use it directly,
+    // otherwise we need to generate one using row number.
+    const query = (function () {
+      if (dataset.implements({id: NUM})) {
+        return dataset.query();
+      } else {
+        return `
+          SELECT
+            ROW_NUMBER() OVER (ORDER BY ts) AS id,
+            *
+          FROM (${dataset.query()})
+        `;
+      }
+    })();
+
+    this.engine
+      .query(
+        `
+        SELECT
+          id
+        FROM (${query})
+        WHERE
+          ts < ${time}
+        ORDER BY ts DESC
+        LIMIT 1
+      `,
+      )
+      .then((result) => {
+        const it = result.iter({
+          id: NUM,
+        });
+        if (!it.valid()) {
+          return;
+        }
+        const id = it.id;
+        this.trace.selection.selectTrackEvent(this.uri, id);
+      });
+
+    return true;
+  }
+}
+
+// Generate a query to use for generating counter data to be rendered
+function generateRenderQuery<T extends DatasetSchema>(
+  dataset: SourceDataset<T>,
+) {
+  const hasId = dataset.implements({id: NUM});
+  const hasTs = dataset.implements({ts: LONG});
+  const hasValue = dataset.implements({value: NUM});
+
+  if (!hasTs) {
+    throw new Error('Counter dataset must have a "ts" column');
+  }
+  if (!hasValue) {
+    throw new Error('Counter dataset must have a "value" column');
+  }
+
+  const cols = removeFalsyValues([
+    // If we have no id, automatically generate one using row number.
+    !hasId && 'ROW_NUMBER() OVER (ORDER BY ts) AS id',
+  ]);
+
+  if (cols.length === 0) {
+    return dataset.query();
+  } else {
+    return `select ${cols.join(', ')}, * from (${dataset.query()})`;
+  }
+}
diff --git a/ui/src/components/tracks/counter_track_details_panel.ts b/ui/src/components/tracks/counter_track_details_panel.ts
new file mode 100644
index 0000000..a13072b
--- /dev/null
+++ b/ui/src/components/tracks/counter_track_details_panel.ts
@@ -0,0 +1,228 @@
+// Copyright (C) 2025 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 {TrackEventDetailsPanel} from '../../public/details_panel';
+import {Trace} from '../../public/trace';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {sqlValueToReadableString} from '../../trace_processor/sql_utils';
+import {DetailsShell} from '../../widgets/details_shell';
+import {GridLayout} from '../../widgets/grid_layout';
+import {Section} from '../../widgets/section';
+import {Tree, TreeNode} from '../../widgets/tree';
+import {Timestamp} from '../widgets/timestamp';
+import {CounterRowSchema} from './counter_track';
+import {exists} from '../../base/utils';
+import {duration, time, Time} from '../../base/time';
+import {
+  LONG,
+  LONG_NULL,
+  NUM,
+  NUM_NULL,
+} from '../../trace_processor/query_result';
+import {Engine} from '../../trace_processor/engine';
+import {Arg} from '../sql_utils/args';
+import {DurationWidget} from '../widgets/duration';
+
+/**
+ * Default details panel for CounterTrack that displays all fields from
+ * the dataset query.
+ *
+ * This panel provides a "better than nothing" experience when no custom
+ * details panel is specified. It automatically shows:
+ * - Common counter fields (ts, value) with appropriate formatting
+ * - All other dataset columns as readable strings
+ */
+export class CounterTrackDetailsPanel<T extends CounterRowSchema>
+  implements TrackEventDetailsPanel
+{
+  private counterDetails?: CounterDetails;
+
+  constructor(
+    private readonly trace: Trace,
+    private readonly dataset: SourceDataset<T>,
+    private readonly data: T,
+    private readonly rootTableOrSubquery?: string,
+  ) {}
+
+  async load() {
+    if (this.data.id !== undefined && this.rootTableOrSubquery) {
+      this.counterDetails = await loadCounterDetails(
+        this.trace.engine,
+        this.data.id,
+        this.rootTableOrSubquery,
+      );
+    }
+  }
+
+  render() {
+    const data = this.data;
+    const details = this.counterDetails;
+
+    return m(
+      DetailsShell,
+      {
+        title: 'Counter',
+      },
+      m(
+        GridLayout,
+        m(
+          Section,
+          {title: 'Details'},
+          m(Tree, [
+            // Basic counter information
+            exists(data.id) &&
+              m(TreeNode, {
+                left: 'ID',
+                right: data.id,
+              }),
+            m(TreeNode, {
+              left: 'Timestamp',
+              right: m(Timestamp, {
+                trace: this.trace,
+                ts: Time.fromRaw(data.ts),
+              }),
+            }),
+            m(TreeNode, {
+              left: 'Value',
+              right: data.value.toLocaleString(),
+            }),
+
+            // Enhanced details if available
+            details && [
+              m(TreeNode, {
+                left: 'Duration to next',
+                right: m(DurationWidget, {
+                  trace: this.trace,
+                  dur: details.duration,
+                }),
+              }),
+              m(TreeNode, {
+                left: 'Delta from previous',
+                right: details.delta.toLocaleString(),
+              }),
+              // Show args if available
+              details.args &&
+                details.args.length > 0 && [
+                  m(TreeNode, {
+                    left: 'Arguments',
+                    right: '',
+                  }),
+                  ...details.args.map((arg) =>
+                    m(TreeNode, {
+                      left: `  ${arg.key}`,
+                      right: arg.displayValue,
+                    }),
+                  ),
+                ],
+            ],
+
+            // List all other fields from the dataset's schema
+            ...Object.keys(this.dataset.schema)
+              .filter((key) => !['id', 'ts', 'value'].includes(key))
+              .map((key) => {
+                const value = data[key];
+                return m(TreeNode, {
+                  left: key,
+                  right: sqlValueToReadableString(value),
+                });
+              }),
+          ]),
+        ),
+      ),
+    );
+  }
+}
+
+interface CounterDetails {
+  // The "left" timestamp of the counter sample T(N)
+  readonly ts: time;
+
+  // The delta between this sample and the next one's timestamps T(N+1) - T(N)
+  readonly duration: duration;
+
+  // The value of the counter sample F(N)
+  readonly value: number;
+
+  // The delta between this sample's value and the previous one F(N) - F(N-1)
+  readonly delta: number;
+
+  readonly args?: Arg[];
+}
+
+async function loadCounterDetails(
+  engine: Engine,
+  id: number,
+  rootTableOrSubquery: string,
+): Promise<CounterDetails> {
+  const query = `
+    WITH data AS (
+      SELECT * FROM (${rootTableOrSubquery})
+    ),
+    CURRENT AS (
+      SELECT
+        id,
+        ts,
+        value
+      FROM data
+      WHERE id = ${id}
+    ),
+    PREV AS (
+      SELECT
+        value
+      FROM data
+      WHERE ts < (SELECT ts FROM CURRENT)
+      ORDER BY ts DESC
+      LIMIT 1
+    ),
+    NEXT AS (
+      SELECT
+        ts
+      FROM data
+      WHERE ts > (SELECT ts FROM CURRENT)
+      ORDER BY ts ASC
+      LIMIT 1
+    )
+    SELECT
+      id,
+      ts as leftTs,
+      value,
+      (SELECT value FROM PREV) as prevValue,
+      (SELECT ts FROM NEXT) as rightTs
+    FROM CURRENT
+  `;
+
+  const counter = await engine.query(query);
+  const row = counter.iter({
+    value: NUM,
+    prevValue: NUM_NULL,
+    leftTs: LONG,
+    rightTs: LONG_NULL,
+  });
+
+  if (!row.valid()) {
+    throw new Error(`Counter with id ${id} not found`);
+  }
+
+  const value = row.value;
+  const leftTs = Time.fromRaw(row.leftTs);
+  const rightTs = row.rightTs !== null ? Time.fromRaw(row.rightTs) : leftTs;
+  const prevValue = row.prevValue !== null ? row.prevValue : value;
+
+  const delta = value - prevValue;
+  const duration = rightTs - leftTs;
+  // const args =
+  //   argSetId == null ? undefined : await getArgs(engine, asArgSetId(argSetId));
+  return {ts: leftTs, value, delta, duration};
+}
diff --git a/ui/src/components/tracks/counter_track_example.ts b/ui/src/components/tracks/counter_track_example.ts
new file mode 100644
index 0000000..a8eff45
--- /dev/null
+++ b/ui/src/components/tracks/counter_track_example.ts
@@ -0,0 +1,94 @@
+// Copyright (C) 2025 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.
+
+/**
+ * Example usage of CounterTrack - this file demonstrates how to use the
+ * CounterTrack implementation and can be used for testing.
+ */
+
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM} from '../../trace_processor/query_result';
+import {CounterTrack, CounterTrackAttrs} from './counter_track';
+import {Trace} from '../../public/trace';
+
+// Example: Creating a counter track for CPU usage
+export function createCpuUsageCounterTrack(trace: Trace, uri: string) {
+  const dataset = new SourceDataset({
+    src: `
+      SELECT ts, value, cpu
+      FROM counter
+      WHERE name = 'CPU Usage'
+      ORDER BY ts
+    `,
+    schema: {
+      ts: LONG,
+      value: NUM,
+      cpu: NUM,
+    },
+  });
+
+  const attrs: CounterTrackAttrs<typeof dataset.schema> = {
+    trace,
+    uri,
+    dataset,
+    defaultOptions: {
+      yMode: 'value',
+      yDisplay: 'zero',
+      yRange: 'all',
+      unit: '%',
+      yOverrideMaximum: 100,
+      yOverrideMinimum: 0,
+    },
+    shellButtons: () => [],
+  };
+
+  return CounterTrack.create(attrs);
+}
+
+// Example: Creating a memory usage counter track with materialization
+export async function createMemoryUsageCounterTrack(trace: Trace, uri: string) {
+  const dataset = new SourceDataset({
+    src: `
+      SELECT 
+        ts,
+        value / 1024 / 1024 as value,
+        name
+      FROM counter 
+      WHERE name LIKE '%memory%'
+      ORDER BY ts
+    `,
+    schema: {
+      ts: LONG,
+      value: NUM,
+      name: 'STRING',
+    },
+  });
+
+  const attrs: CounterTrackAttrs<typeof dataset.schema> = {
+    trace,
+    uri,
+    dataset,
+    defaultOptions: {
+      yMode: 'value',
+      yDisplay: 'zero',
+      unit: 'MB',
+      chartHeightSize: 4,
+    },
+    tooltip: (row, formattedValue) =>
+      `Memory: ${formattedValue} at ${row.name}`,
+  };
+
+  // Use materialized version for complex queries
+  return CounterTrack.createMaterialized(attrs);
+}
diff --git a/ui/src/components/tracks/debug_tracks.ts b/ui/src/components/tracks/debug_tracks.ts
index 104ab3c..ac0ee24 100644
--- a/ui/src/components/tracks/debug_tracks.ts
+++ b/ui/src/components/tracks/debug_tracks.ts
@@ -27,10 +27,12 @@
   RAW_PREFIX,
   DebugSliceTrackDetailsPanel,
 } from './debug_slice_track_details_panel';
-import {
-  CounterColumnMapping,
-  SqlTableCounterTrack,
-} from './query_counter_track';
+import {BaseCounterTrack, CounterOptions} from './base_counter_track';
+
+export interface CounterColumnMapping {
+  readonly ts: string;
+  readonly value: string;
+}
 
 export interface SqlDataSource {
   // SQL source selecting the necessary data.
@@ -398,3 +400,18 @@
   const trackNode = new TrackNode({uri, name, removable: true});
   trace.workspace.pinnedTracksNode.addChildLast(trackNode);
 }
+
+export class SqlTableCounterTrack extends BaseCounterTrack {
+  constructor(
+    trace: Trace,
+    uri: string,
+    private readonly sqlSource: string,
+    options?: Partial<CounterOptions>,
+  ) {
+    super(trace, uri, options);
+  }
+
+  getSqlSource(): string {
+    return `select * from (${this.sqlSource})`;
+  }
+}
diff --git a/ui/src/components/tracks/query_counter_track.ts b/ui/src/components/tracks/query_counter_track.ts
deleted file mode 100644
index 401f8fb..0000000
--- a/ui/src/components/tracks/query_counter_track.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-// 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 {createPerfettoTable} from '../../trace_processor/sql_utils';
-import {Trace} from '../../public/trace';
-import {sqlNameSafe} from '../../base/string_utils';
-import {BaseCounterTrack, CounterOptions} from './base_counter_track';
-import {Engine} from '../../trace_processor/engine';
-
-export interface QueryCounterTrackArgs {
-  // The trace object used to run queries.
-  readonly trace: Trace;
-
-  // A unique, reproducible ID for this track.
-  readonly uri: string;
-
-  // The query and optional column remapping.
-  readonly data: SqlDataSource;
-
-  // Optional: Which columns should be used for ts, and value. If omitted,
-  // the defaults 'ts', and 'value' will be used.
-  readonly columns?: Partial<CounterColumnMapping>;
-
-  // Optional: Display options for the counter track.
-  readonly options?: Partial<CounterOptions>;
-
-  // Optional: Whether to materialize the query results. If omitted, we
-  // will materialize.
-  readonly materialize?: boolean;
-}
-
-export interface SqlDataSource {
-  // SQL source selecting the necessary data.
-  readonly sqlSource: string;
-
-  // Optional: Rename columns from the query result.
-  // If omitted, original column names from the query are used instead.
-  // The caller is responsible for ensuring that the number of items in this
-  // list matches the number of columns returned by sqlSource.
-  readonly columns?: string[];
-}
-
-export interface CounterColumnMapping {
-  readonly ts: string;
-  readonly value: string;
-}
-
-/**
- * Creates a counter track based on a query.
- *
- * The query must provide the following columns:
- * - ts: INTEGER - The timestamp of each sample.
- * - value: REAL | INTEGER - The value of each sample.
- *
- * The column names don't have to be 'ts' and 'value', and can be remapped if
- * convenient using the config.columns parameter.
- */
-export async function createQueryCounterTrack(args: QueryCounterTrackArgs) {
-  if (args.materialize === false) {
-    return new SqlTableCounterTrack(
-      args.trace,
-      args.uri,
-      wrapQueryForCounterTrack(args.data, args.columns),
-      args.options,
-    );
-  } else {
-    const tableName = `__query_counter_track_${sqlNameSafe(args.uri)}`;
-    await createPerfettoTableForTrack(
-      args.trace.engine,
-      tableName,
-      args.data,
-      args.columns,
-    );
-    return new SqlTableCounterTrack(
-      args.trace,
-      args.uri,
-      tableName,
-      args.options,
-    );
-  }
-}
-
-function wrapQueryForCounterTrack(
-  data: SqlDataSource,
-  columnMapping: Partial<CounterColumnMapping> = {},
-) {
-  const {ts = 'ts', value = 'value'} = columnMapping;
-  return `
-    with data as (
-      ${data.sqlSource}
-    )
-    select
-      ${ts} as ts,
-      ${value} as value
-    from data
-    order by ts
-  `;
-}
-
-async function createPerfettoTableForTrack(
-  engine: Engine,
-  tableName: string,
-  data: SqlDataSource,
-  columnMapping: Partial<CounterColumnMapping> = {},
-) {
-  return await createPerfettoTable({
-    engine,
-    name: tableName,
-    as: wrapQueryForCounterTrack(data, columnMapping),
-  });
-}
-
-export class SqlTableCounterTrack extends BaseCounterTrack {
-  constructor(
-    trace: Trace,
-    uri: string,
-    private readonly sqlSource: string,
-    options?: Partial<CounterOptions>,
-  ) {
-    super(trace, uri, options);
-  }
-
-  getSqlSource(): string {
-    return `select * from (${this.sqlSource})`;
-  }
-}
diff --git a/ui/src/plugins/com.android.AndroidCounterTracks/index.ts b/ui/src/plugins/com.android.AndroidCounterTracks/index.ts
index d0ba296..136ec90 100644
--- a/ui/src/plugins/com.android.AndroidCounterTracks/index.ts
+++ b/ui/src/plugins/com.android.AndroidCounterTracks/index.ts
@@ -12,10 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
+import {
+  CounterRowSchema,
+  CounterTrack,
+} from '../../components/tracks/counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
 import {Trace} from '../../public/trace';
 import {TrackNode} from '../../public/workspace';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM} from '../../trace_processor/query_result';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'com.android.AndroidCounterTracks';
@@ -25,15 +30,21 @@
 
     await engine.query(`include perfetto module android.binder;`);
 
-    const counterTrackSource = `
-      select client_ts as ts, count(*) value
-      from android_binder_txns
-      group by ts
-    `;
-
     const trackNode = await this.loadCounterTrack(
       ctx,
-      counterTrackSource,
+      new SourceDataset({
+        src: `
+          SELECT
+            client_ts AS ts,
+            COUNT(*) value
+          FROM android_binder_txns
+          GROUP BY ts
+        `,
+        schema: {
+          ts: LONG,
+          value: NUM,
+        },
+      }),
       '/android_counter_track',
       'Android Counter Track',
     );
@@ -42,17 +53,14 @@
 
   private async loadCounterTrack(
     ctx: Trace,
-    sqlSource: string,
+    dataset: SourceDataset<CounterRowSchema>,
     uri: string,
     title: string,
   ) {
-    const track = await createQueryCounterTrack({
+    const track = await CounterTrack.createMaterialized({
       trace: ctx,
       uri,
-      data: {
-        sqlSource,
-        columns: ['ts', 'value'],
-      },
+      dataset,
     });
 
     ctx.tracks.registerTrack({
diff --git a/ui/src/plugins/com.android.AndroidDmabuf/index.ts b/ui/src/plugins/com.android.AndroidDmabuf/index.ts
index dc80b36..7560e4a 100644
--- a/ui/src/plugins/com.android.AndroidDmabuf/index.ts
+++ b/ui/src/plugins/com.android.AndroidDmabuf/index.ts
@@ -13,29 +13,30 @@
 // limitations under the License.
 
 import {
-  createQueryCounterTrack,
-  SqlDataSource,
-} from '../../components/tracks/query_counter_track';
+  CounterRowSchema,
+  CounterTrack,
+} from '../../components/tracks/counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
 import {Trace} from '../../public/trace';
 import {COUNTER_TRACK_KIND, SLICE_TRACK_KIND} from '../../public/track_kinds';
 import {TrackNode} from '../../public/workspace';
-import {NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
 import ProcessThreadGroupsPlugin from '../dev.perfetto.ProcessThreadGroups';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
 import TraceProcessorTrackPlugin from '../dev.perfetto.TraceProcessorTrack';
 import {TraceProcessorCounterTrack} from '../dev.perfetto.TraceProcessorTrack/trace_processor_counter_track';
 import {createTraceProcessorSliceTrack} from '../dev.perfetto.TraceProcessorTrack/trace_processor_slice_track';
 
-async function registerAllocsTrack(
+async function registerAllocsTrack<T extends CounterRowSchema>(
   ctx: Trace,
   uri: string,
-  dataSource: SqlDataSource,
+  dataset: SourceDataset<T>,
 ) {
-  const track = await createQueryCounterTrack({
+  const track = await CounterTrack.createMaterialized({
     trace: ctx,
     uri,
-    data: dataSource,
+    dataset,
   });
   ctx.tracks.registerTrack({
     uri,
@@ -55,29 +56,50 @@
     const e = ctx.engine;
     await e.query(`INCLUDE PERFETTO MODULE android.memory.dmabuf`);
 
-    const pids = await e.query(
-      `SELECT DISTINCT upid, IIF(upid IS NULL, utid, NULL) AS utid FROM android_memory_cumulative_dmabuf`,
-    );
+    const pids = await e.query(`
+      SELECT DISTINCT
+        upid,
+        IIF(upid IS NULL, utid, NULL) AS utid
+      FROM android_memory_cumulative_dmabuf
+    `);
     const it = pids.iter({upid: NUM_NULL, utid: NUM_NULL});
     for (; it.valid(); it.next()) {
       if (it.upid != null) {
         const uri = `/android_process_dmabuf_upid_${it.upid}`;
-        const config: SqlDataSource = {
-          sqlSource: `SELECT ts, value FROM android_memory_cumulative_dmabuf
-                 WHERE upid = ${it.upid}`,
-        };
-        await registerAllocsTrack(ctx, uri, config);
+        const dataset = new SourceDataset({
+          src: `
+            SELECT
+              ts,
+              value
+            FROM android_memory_cumulative_dmabuf
+            WHERE upid = ${it.upid}
+          `,
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+        });
+        await registerAllocsTrack(ctx, uri, dataset);
         ctx.plugins
           .getPlugin(ProcessThreadGroupsPlugin)
           .getGroupForProcess(it.upid)
           ?.addChildInOrder(new TrackNode({uri, name: 'dmabuf allocs'}));
       } else if (it.utid != null) {
         const uri = `/android_process_dmabuf_utid_${it.utid}`;
-        const config: SqlDataSource = {
-          sqlSource: `SELECT ts, value FROM android_memory_cumulative_dmabuf
-                 WHERE utid = ${it.utid}`,
-        };
-        await registerAllocsTrack(ctx, uri, config);
+        const dataset = new SourceDataset({
+          src: `
+            SELECT
+              ts,
+              value
+            FROM android_memory_cumulative_dmabuf
+            WHERE utid = ${it.utid}
+          `,
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+        });
+        await registerAllocsTrack(ctx, uri, dataset);
         ctx.plugins
           .getPlugin(ProcessThreadGroupsPlugin)
           .getGroupForThread(it.utid)
diff --git a/ui/src/plugins/com.android.AndroidLongBatterySupport/index.ts b/ui/src/plugins/com.android.AndroidLongBatterySupport/index.ts
index 925e216..0ff9f6f 100644
--- a/ui/src/plugins/com.android.AndroidLongBatterySupport/index.ts
+++ b/ui/src/plugins/com.android.AndroidLongBatterySupport/index.ts
@@ -18,10 +18,10 @@
 import {Engine} from '../../trace_processor/engine';
 import {SliceTrack, RowSchema} from '../../components/tracks/slice_track';
 import {CounterOptions} from '../../components/tracks/base_counter_track';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import {TrackNode} from '../../public/workspace';
 import {SourceDataset} from '../../trace_processor/dataset';
-import {STR} from '../../trace_processor/query_result';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
+import {CounterTrack} from '../../components/tracks/counter_track';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'com.android.AndroidLongBatterySupport';
@@ -93,14 +93,17 @@
     groupCollapsed = true,
   ) {
     const uri = `/long_battery_tracing_${name}`;
-    const track = await createQueryCounterTrack({
+    const track = await CounterTrack.createMaterialized({
       trace: ctx,
       uri,
-      data: {
-        sqlSource: query,
-        columns: ['ts', 'value'],
-      },
-      options,
+      dataset: new SourceDataset({
+        src: query,
+        schema: {
+          ts: LONG,
+          value: NUM,
+        },
+      }),
+      defaultOptions: options,
     });
     ctx.tracks.registerTrack({
       uri,
diff --git a/ui/src/plugins/com.android.CpuPerUid/index.ts b/ui/src/plugins/com.android.CpuPerUid/index.ts
index baf3e15..1b53078 100644
--- a/ui/src/plugins/com.android.CpuPerUid/index.ts
+++ b/ui/src/plugins/com.android.CpuPerUid/index.ts
@@ -15,9 +15,13 @@
 import {Trace} from '../../public/trace';
 import {PerfettoPlugin} from '../../public/plugin';
 import {TrackNode} from '../../public/workspace';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
-import {NUM, STR} from '../../trace_processor/query_result';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
+import {
+  CounterRowSchema,
+  CounterTrack,
+} from '../../components/tracks/counter_track';
+import {SourceDataset} from '../../trace_processor/dataset';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'com.android.CpuPerUid';
@@ -25,29 +29,24 @@
 
   private _topLevelGroup: TrackNode | undefined;
 
-  private async addCpuPerUidTrack(
+  private addCpuPerUidTrack(
     ctx: Trace,
-    sql: string,
+    dataset: SourceDataset<CounterRowSchema>,
     name: string,
     uri: string,
     group: TrackNode,
     sharing?: string,
   ) {
-    const track = await createQueryCounterTrack({
+    const track = CounterTrack.create({
       trace: ctx,
       uri,
-      data: {
-        sqlSource: sql,
-        columns: ['ts', 'value'],
-      },
-      columns: {ts: 'ts', value: 'value'},
-      options: {
+      dataset,
+      defaultOptions: {
         unit: '%',
         yOverrideMaximum: 100,
         yOverrideMinimum: 0,
         yRangeSharingKey: sharing,
       },
-      materialize: false,
     });
     ctx.tracks.registerTrack({
       uri,
@@ -95,11 +94,20 @@
 
       for (; it.valid(); it.next()) {
         const name = `${it.type} (${clusterName(it.cluster)})`;
-        await this.addCpuPerUidTrack(
+        this.addCpuPerUidTrack(
           ctx,
-          `select ts, value
-          from _android_cpu_per_uid_summary
-          where type = '${it.type}' and cluster = ${it.cluster}`,
+          new SourceDataset({
+            src: `
+              SELECT
+                ts,
+                value
+              FROM _android_cpu_per_uid_summary
+              WHERE type = '${it.type}' AND cluster = ${it.cluster}`,
+            schema: {
+              ts: LONG,
+              value: NUM,
+            },
+          }),
           name,
           `/cpu_per_uid_summary_${it.type}_${it.cluster}`,
           group,
@@ -137,13 +145,25 @@
 
       for (; it.valid(); it.next()) {
         const name = `${it.name} (${clusterName(it.cluster)})`;
-        await this.addCpuPerUidTrack(
+        this.addCpuPerUidTrack(
           ctx,
-          `select
-           ts,
-           min(100, 100 * cpu_ratio) as value
-         from android_cpu_per_uid_counter
-         where track_id = ${it.id}`,
+          new SourceDataset({
+            src: `
+              SELECT
+                ts,
+                MIN(100, 100 * cpu_ratio) AS value,
+                track_id
+              FROM android_cpu_per_uid_counter
+            `,
+            schema: {
+              ts: LONG,
+              value: NUM,
+            },
+            filter: {
+              col: 'track_id',
+              eq: it.id,
+            },
+          }),
           name,
           `/${uriPrefix}_${it.id}`,
           group,
diff --git a/ui/src/plugins/com.android.DayExplorer/index.ts b/ui/src/plugins/com.android.DayExplorer/index.ts
index b87f933..0295560 100644
--- a/ui/src/plugins/com.android.DayExplorer/index.ts
+++ b/ui/src/plugins/com.android.DayExplorer/index.ts
@@ -16,9 +16,8 @@
 import {Trace} from '../../public/trace';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
 import {PerfettoPlugin} from '../../public/plugin';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import {TrackNode} from '../../public/workspace';
-import {STR, LONG, LONG_NULL} from '../../trace_processor/query_result';
+import {STR, LONG, NUM, LONG_NULL} from '../../trace_processor/query_result';
 import {SourceDataset} from '../../trace_processor/dataset';
 import {AreaSelection, areaSelectionsEqual} from '../../public/selection';
 import {Flamegraph} from '../../widgets/flamegraph';
@@ -27,6 +26,10 @@
   QueryFlamegraph,
 } from '../../components/query_flamegraph';
 import SupportPlugin from '../com.android.AndroidLongBatterySupport';
+import {
+  CounterRowSchema,
+  CounterTrack,
+} from '../../components/tracks/counter_track';
 
 const DAY_EXPLORER_TRACK_KIND = 'day_explorer_counter_track';
 
@@ -75,18 +78,30 @@
     });
 
     for (; childIter.valid(); childIter.next()) {
-      const query = `
-        SELECT ts, power_mw AS value
-        FROM day_explorer_ui_hierarchy_per_ts
-        WHERE track_id = ${childIter.track_id}
-      `;
+      const dataset = new SourceDataset({
+        src: `
+          SELECT
+            ts,
+            power_mw AS value,
+            track_id
+          FROM day_explorer_ui_hierarchy_per_ts
+        `,
+        schema: {
+          ts: LONG,
+          value: NUM,
+        },
+        filter: {
+          col: 'track_id',
+          eq: childIter.track_id,
+        },
+      });
       const groupKey = `_day_explorer_ui_hierarchy_under_${parentId}`;
       const trackName = `${childIter.display_name} - ${childIter.energy_mwh}mWh`;
       const node = await this.createDayExplorerTrack(
         ctx,
         trackName,
         groupKey,
-        query,
+        dataset,
       );
       parent.addChildInOrder(node);
       await this.addDayExplorerRecursive(ctx, node, limit, childIter.track_id);
@@ -97,20 +112,14 @@
     ctx: Trace,
     name: string,
     groupKey: string,
-    query: string,
+    dataset: SourceDataset<CounterRowSchema>,
   ): Promise<TrackNode> {
     const uri = `/day_explorer_${uuidv4()}`;
-    const renderer = await createQueryCounterTrack({
+    const renderer = await CounterTrack.createMaterialized({
       trace: ctx,
       uri,
-      data: {
-        sqlSource: query,
-      },
-      columns: {
-        ts: 'ts',
-        value: 'value',
-      },
-      options: {
+      dataset,
+      defaultOptions: {
         yRangeSharingKey: groupKey,
       },
     });
diff --git a/ui/src/plugins/com.google.PixelCpmTrace/index.ts b/ui/src/plugins/com.google.PixelCpmTrace/index.ts
index 268df77..3072ca1 100644
--- a/ui/src/plugins/com.google.PixelCpmTrace/index.ts
+++ b/ui/src/plugins/com.google.PixelCpmTrace/index.ts
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
+import {CounterTrack} from '../../components/tracks/counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
 import {Trace} from '../../public/trace';
 import {COUNTER_TRACK_KIND} from '../../public/track_kinds';
 import {TrackNode} from '../../public/workspace';
-import {NUM, STR} from '../../trace_processor/query_result';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'com.google.PixelCpmTrace';
@@ -42,18 +43,20 @@
     for (let groupAdded = false; it.valid(); it.next()) {
       const {trackId, trackName} = it;
       const uri = `/cpm_trace_${trackName}`;
-      const track = await createQueryCounterTrack({
+      const track = CounterTrack.create({
         trace: ctx,
         uri,
-        data: {
-          sqlSource: `
-             select ts, value
-             from counter
-             where track_id = ${trackId}
-           `,
-          columns: ['ts', 'value'],
-        },
-        columns: {ts: 'ts', value: 'value'},
+        dataset: new SourceDataset({
+          src: 'counter',
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+          filter: {
+            col: 'track_id',
+            eq: trackId,
+          },
+        }),
       });
       ctx.tracks.registerTrack({
         uri,
diff --git a/ui/src/plugins/dev.perfetto.CpuidleTimeInState/index.ts b/ui/src/plugins/dev.perfetto.CpuidleTimeInState/index.ts
index fb54239..9f7ddcd 100644
--- a/ui/src/plugins/dev.perfetto.CpuidleTimeInState/index.ts
+++ b/ui/src/plugins/dev.perfetto.CpuidleTimeInState/index.ts
@@ -16,9 +16,13 @@
 import {PerfettoPlugin} from '../../public/plugin';
 import {CounterOptions} from '../../components/tracks/base_counter_track';
 import {TrackNode} from '../../public/workspace';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
-import {NUM, STR} from '../../trace_processor/query_result';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
+import {
+  CounterRowSchema,
+  CounterTrack,
+} from '../../components/tracks/counter_track';
+import {SourceDataset} from '../../trace_processor/dataset';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.CpuidleTimeInState';
@@ -27,20 +31,16 @@
   private async addCounterTrack(
     ctx: Trace,
     name: string,
-    query: string,
+    dataset: SourceDataset<CounterRowSchema>,
     group: TrackNode,
-    options?: Partial<CounterOptions>,
+    _options?: Partial<CounterOptions>,
   ) {
     const uri = `/cpuidle_time_in_state_${name}`;
-    const track = await createQueryCounterTrack({
+    const track = await CounterTrack.createMaterialized({
       trace: ctx,
       uri,
-      data: {
-        sqlSource: query,
-        columns: ['ts', 'value'],
-      },
-      columns: {ts: 'ts', value: 'value'},
-      options,
+      dataset,
+      // options,
     });
     ctx.tracks.registerTrack({
       uri,
@@ -58,13 +58,19 @@
     await this.addCounterTrack(
       ctx,
       `cpuidle.${state}`,
-      `
-        select
-          ts,
-          idle_percentage as value
-        from linux_cpu_idle_time_in_state_counters
-        where state = '${state}'
-      `,
+      new SourceDataset({
+        src: `
+          SELECT
+            ts,
+            idle_percentage AS value
+          FROM linux_cpu_idle_time_in_state_counters
+          WHERE state = '${state}'
+        `,
+        schema: {
+          ts: LONG,
+          value: NUM,
+        },
+      }),
       group,
       {unit: 'percent', yOverrideMaximum: 100, yOverrideMinimum: 0},
     );
@@ -79,13 +85,19 @@
     await this.addCounterTrack(
       ctx,
       `cpuidle.cpu${cpu}.${state} Residency`,
-      `
-        select
-          ts,
-          idle_percentage as value
-        from linux_per_cpu_idle_time_in_state_counters
-        where state = '${state}' AND cpu = ${cpu}
-      `,
+      new SourceDataset({
+        src: `
+          SELECT
+            ts,
+            idle_percentage AS value
+          FROM linux_per_cpu_idle_time_in_state_counters
+          WHERE state = '${state}' AND cpu = ${cpu}
+        `,
+        schema: {
+          ts: LONG,
+          value: NUM,
+        },
+      }),
       group,
       {unit: 'percent', yOverrideMaximum: 100, yOverrideMinimum: 0},
     );
diff --git a/ui/src/plugins/dev.perfetto.EntityStateResidency/index.ts b/ui/src/plugins/dev.perfetto.EntityStateResidency/index.ts
index 2c65366..f45a329 100644
--- a/ui/src/plugins/dev.perfetto.EntityStateResidency/index.ts
+++ b/ui/src/plugins/dev.perfetto.EntityStateResidency/index.ts
@@ -13,12 +13,13 @@
 // limitations under the License.
 
 import {createAggregationTab} from '../../components/aggregation_adapter';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
+import {CounterTrack} from '../../components/tracks/counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
 import {Trace} from '../../public/trace';
 import {COUNTER_TRACK_KIND} from '../../public/track_kinds';
 import {TrackNode} from '../../public/workspace';
-import {NUM, STR} from '../../trace_processor/query_result';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
 import {EntityStateResidencySelectionAggregator} from './entity_state_residency_selection_aggregator';
 
@@ -73,21 +74,23 @@
       // Create and register a track for the state residency.
       const uri = `/entity_state_residency_${it.entity}_${it.state}`;
       const name = it.state;
-      const track = await createQueryCounterTrack({
+      const track = await CounterTrack.createMaterialized({
         trace: ctx,
         uri,
-        data: {
-          sqlSource: `
+        dataset: new SourceDataset({
+          src: `
               SELECT
                 ts,
                 state_time_since_boot / 1e9 AS value
               FROM android_entity_state_residency
               WHERE track_id = ${it.trackId}
             `,
-          columns: ['ts', 'value'],
-        },
-        columns: {ts: 'ts', value: 'value'},
-        options: {
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+        }),
+        defaultOptions: {
           yMode: 'rate',
           yRangeSharingKey: `entity_state_residency_${it.entity}`,
           unit: 's',
diff --git a/ui/src/plugins/dev.perfetto.PowerRails/index.ts b/ui/src/plugins/dev.perfetto.PowerRails/index.ts
index be1320c..a478ea8 100644
--- a/ui/src/plugins/dev.perfetto.PowerRails/index.ts
+++ b/ui/src/plugins/dev.perfetto.PowerRails/index.ts
@@ -13,13 +13,19 @@
 // limitations under the License.
 
 import {createAggregationTab} from '../../components/aggregation_adapter';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
+import {CounterTrack} from '../../components/tracks/counter_track';
 import {PerfettoPlugin} from '../../public/plugin';
 import {Trace} from '../../public/trace';
 import {COUNTER_TRACK_KIND} from '../../public/track_kinds';
 import {getTrackName} from '../../public/utils';
 import {TrackNode} from '../../public/workspace';
-import {NUM, NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {
+  LONG,
+  NUM,
+  NUM_NULL,
+  STR_NULL,
+} from '../../trace_processor/query_result';
 import StandardGroupsPlugin from '../dev.perfetto.StandardGroups';
 import {PowerCounterSelectionAggregator} from './power_counter_selection_aggregator';
 
@@ -77,19 +83,27 @@
         machine,
       });
       const uri = `/counter_${trackId}`;
-      const track = await createQueryCounterTrack({
+      const track = await CounterTrack.createMaterialized({
         trace: ctx,
         uri,
-        data: {
-          sqlSource: `
+        dataset: new SourceDataset({
+          src: `
             SELECT
               ts,
-              value / 1000.0 AS value -- convert uJ to mJ
+              value / 1000.0 AS value, -- convert uJ to mJ
+              track_id
             FROM counter
-            WHERE track_id = ${trackId}
           `,
-        },
-        options: {
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+          filter: {
+            col: 'track_id',
+            eq: trackId,
+          },
+        }),
+        defaultOptions: {
           yMode: 'rate',
           yRangeSharingKey: 'power_rails',
           unit: 'mJ',
diff --git a/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_counter_track.ts b/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_counter_track.ts
index e0be4ac..66738b3 100644
--- a/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_counter_track.ts
+++ b/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_counter_track.ts
@@ -113,6 +113,11 @@
   }
 
   detailsPanel() {
-    return new CounterDetailsPanel(this.trace, this.trackId, this.trackName);
+    return new CounterDetailsPanel(
+      this.trace,
+      this.trackId,
+      this.trackName,
+      this.rootTable,
+    );
   }
 }
diff --git a/ui/src/plugins/org.chromium.ETM/index.ts b/ui/src/plugins/org.chromium.ETM/index.ts
index 9dd27c9..a43b70f 100644
--- a/ui/src/plugins/org.chromium.ETM/index.ts
+++ b/ui/src/plugins/org.chromium.ETM/index.ts
@@ -14,9 +14,10 @@
 
 import {Trace} from '../../public/trace';
 import {PerfettoPlugin} from '../../public/plugin';
-
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import {TrackNode} from '../../public/workspace';
+import {CounterTrack} from '../../components/tracks/counter_track';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM} from '../../trace_processor/query_result';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'org.chromium.ETM';
@@ -24,17 +25,25 @@
   async onTraceLoad(trace: Trace) {
     const title = 'ETM Session ID';
     const uri = `${trace.pluginId}#ETMSessionID`;
-    const query =
-      'select ts, value from counter inner join ' +
-      'counter_track on counter_track.id = counter.track_id ' +
-      'where name = "ETMSession"';
-
-    const renderer = await createQueryCounterTrack({
+    const renderer = await CounterTrack.createMaterialized({
       trace,
       uri,
-      data: {
-        sqlSource: query,
-      },
+      dataset: new SourceDataset({
+        src: `
+          SELECT
+            counter.id AS id
+            ts,
+            value
+          FROM counter
+          INNER JOIN counter_track ON counter_track.id = counter.track_id
+          WHERE name = "ETMSession"
+        `,
+        schema: {
+          id: NUM,
+          ts: LONG,
+          value: NUM,
+        },
+      }),
     });
 
     trace.tracks.registerTrack({
diff --git a/ui/src/plugins/org.kernel.Io/index.ts b/ui/src/plugins/org.kernel.Io/index.ts
index 0939292..7303e16 100644
--- a/ui/src/plugins/org.kernel.Io/index.ts
+++ b/ui/src/plugins/org.kernel.Io/index.ts
@@ -15,9 +15,10 @@
 import {TrackNode} from '../../public/workspace';
 import {Trace} from '../../public/trace';
 import {PerfettoPlugin} from '../../public/plugin';
-import {createQueryCounterTrack} from '../../components/tracks/query_counter_track';
 import {Engine} from '../../trace_processor/engine';
-import {NUM} from '../../trace_processor/query_result';
+import {LONG, NUM} from '../../trace_processor/query_result';
+import {CounterTrack} from '../../components/tracks/counter_track';
+import {SourceDataset} from '../../trace_processor/dataset';
 
 export default class implements PerfettoPlugin {
   static readonly id = 'dev.perfetto.Io';
@@ -32,16 +33,22 @@
     for (const device of devices) {
       const uri = `/queued_io_request_count/device_${device['id']}`;
       const name = `dev major:${device['major']} minor:${device['minor']}`;
-      const track = await createQueryCounterTrack({
+      const track = await CounterTrack.createMaterialized({
         trace: ctx,
         uri,
-        data: {
-          sqlSource: `
-            SELECT ts, ops_in_queue_or_device as value
+        dataset: new SourceDataset({
+          src: `
+            SELECT 
+              ts,
+              ops_in_queue_or_device as value
             FROM linux_active_block_io_operations_by_device
             WHERE dev = ${String(device['id'])}
           `,
-        },
+          schema: {
+            ts: LONG,
+            value: NUM,
+          },
+        }),
       });
       ctx.tracks.registerTrack({
         uri,