Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion ui/src/components/widgets/charts/bar_chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@

import m from 'mithril';
import type {EChartsCoreOption} from 'echarts/core';
import {ChartAggregation, extractBrushRange, formatNumber} from './chart_utils';
import {
ChartAggregation,
extractBrushRange,
formatNumber,
percentile,
} from './chart_utils';
import {EChartView, EChartEventHandler} from './echart_view';
import {
buildAxisOption,
Expand Down Expand Up @@ -348,5 +353,12 @@ function aggregate(values: number[], agg: ChartAggregation): number {
return values.reduce((a, b) => Math.max(a, b), -Infinity);
case 'COUNT_DISTINCT':
return new Set(values).size;
case 'P25':
case 'P50':
case 'P75':
case 'P90':
case 'P95':
case 'P99':
return percentile(values, Number(agg.slice(1)));
}
}
4 changes: 3 additions & 1 deletion ui/src/components/widgets/charts/chart_sql_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,9 @@ ORDER BY _bucket_idx`.trim();
private buildHavingClause(measures: ReadonlyArray<MeasureSpec>): string {
const conditions: string[] = [];
for (const m of measures) {
if (m.aggregation === 'COUNT') continue;
if (m.aggregation === 'COUNT' || m.aggregation === 'COUNT_DISTINCT') {
continue;
}
conditions.push(
`${sqlAggregateExpr(m.aggregation, m.column)} IS NOT NULL`,
);
Expand Down
14 changes: 14 additions & 0 deletions ui/src/components/widgets/charts/chart_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ export function isIntegerAggregation(agg: ChartAggregation): boolean {
return agg === 'COUNT' || agg === 'COUNT_DISTINCT';
}

/**
* Compute the p-th percentile of a numeric array using linear interpolation.
* Returns NaN for empty arrays.
*/
export function percentile(values: number[], p: number): number {
if (values.length === 0) return NaN;
const sorted = [...values].sort((a, b) => a - b);
const idx = (p / 100) * (sorted.length - 1);
const lower = Math.floor(idx);
const upper = Math.ceil(idx);
if (lower === upper) return sorted[lower];
return sorted[lower] + (sorted[upper] - sorted[lower]) * (idx - lower);
}

// ---------------------------------------------------------------------------
// SQL helpers shared across chart loaders
// ---------------------------------------------------------------------------
Expand Down
62 changes: 62 additions & 0 deletions ui/src/components/widgets/charts/chart_utils_unittest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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 {percentile} from './chart_utils';

describe('percentile', () => {
test('empty array returns NaN', () => {
expect(percentile([], 50)).toBeNaN();
});

test('single element returns that element', () => {
expect(percentile([42], 50)).toBe(42);
expect(percentile([42], 0)).toBe(42);
expect(percentile([42], 100)).toBe(42);
});

test('P0 returns minimum', () => {
expect(percentile([10, 20, 30, 40, 50], 0)).toBe(10);
});

test('P100 returns maximum', () => {
expect(percentile([10, 20, 30, 40, 50], 100)).toBe(50);
});

test('P50 of even count interpolates', () => {
expect(percentile([10, 20], 50)).toBe(15);
});

test('P50 of odd count returns middle', () => {
expect(percentile([10, 20, 30], 50)).toBe(20);
});

test('P25 interpolation', () => {
expect(percentile([1, 2, 3, 4, 5], 25)).toBe(2);
});

test('P75 interpolation', () => {
expect(percentile([1, 2, 3, 4, 5], 75)).toBe(4);
});

test('unsorted input is handled correctly', () => {
expect(percentile([50, 10, 30, 20, 40], 50)).toBe(30);
});

test('does not mutate input array', () => {
const input = [5, 3, 1, 4, 2];
const copy = [...input];
percentile(input, 50);
expect(input).toEqual(copy);
});
});
5 changes: 4 additions & 1 deletion ui/src/components/widgets/datagrid/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@

import {SqlValue} from '../../../trace_processor/query_result';

type PercentileAggregation = 'P25' | 'P50' | 'P75' | 'P90' | 'P95' | 'P99';

export type AggregateFunction =
| 'ANY'
| 'SUM'
| 'AVG'
| 'MIN'
| 'MAX'
| 'COUNT_DISTINCT';
| 'COUNT_DISTINCT'
| PercentileAggregation;
export type SortDirection = 'ASC' | 'DESC';
export type GroupDisplay = 'flat' | 'tree';
export const DEFAULT_GROUP_DISPLAY: GroupDisplay = 'flat';
Expand Down
7 changes: 7 additions & 0 deletions ui/src/components/widgets/datagrid/sql_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ export function sqlAggregateExpr(
return `MIN(${field})`;
case 'COUNT_DISTINCT':
return `COUNT(DISTINCT ${field})`;
case 'P25':
case 'P50':
case 'P75':
case 'P90':
case 'P95':
case 'P99':
return `PERCENTILE(${field}, ${func.slice(1)})`;
case 'SUM':
case 'AVG':
case 'MIN':
Expand Down
53 changes: 53 additions & 0 deletions ui/src/components/widgets/datagrid/sql_utils_unittest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import {
filterToSql,
sqlAggregateExpr,
sqlPathMatch,
sqlPathNotMatch,
sqlPathsIn,
Expand Down Expand Up @@ -235,6 +236,58 @@ describe('sqlPathsNotIn', () => {
});
});

describe('sqlAggregateExpr', () => {
test('SUM', () => {
expect(sqlAggregateExpr('SUM', 'dur')).toBe('SUM(dur)');
});

test('AVG', () => {
expect(sqlAggregateExpr('AVG', 'dur')).toBe('AVG(dur)');
});

test('MIN', () => {
expect(sqlAggregateExpr('MIN', 'dur')).toBe('MIN(dur)');
});

test('MAX', () => {
expect(sqlAggregateExpr('MAX', 'dur')).toBe('MAX(dur)');
});

test('ANY maps to MIN', () => {
expect(sqlAggregateExpr('ANY', 'name')).toBe('MIN(name)');
});

test('COUNT_DISTINCT', () => {
expect(sqlAggregateExpr('COUNT_DISTINCT', 'name')).toBe(
'COUNT(DISTINCT name)',
);
});

test('P25', () => {
expect(sqlAggregateExpr('P25', 'dur')).toBe('PERCENTILE(dur, 25)');
});

test('P50', () => {
expect(sqlAggregateExpr('P50', 'dur')).toBe('PERCENTILE(dur, 50)');
});

test('P75', () => {
expect(sqlAggregateExpr('P75', 'dur')).toBe('PERCENTILE(dur, 75)');
});

test('P90', () => {
expect(sqlAggregateExpr('P90', 'dur')).toBe('PERCENTILE(dur, 90)');
});

test('P95', () => {
expect(sqlAggregateExpr('P95', 'dur')).toBe('PERCENTILE(dur, 95)');
});

test('P99', () => {
expect(sqlAggregateExpr('P99', 'dur')).toBe('PERCENTILE(dur, 99)');
});
});

describe('filterToSql', () => {
test('equality', () => {
expect(filterToSql({field: 'x', op: '=', value: 'foo'}, 'col')).toBe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,39 @@ import {Select} from '../../../../widgets/select';
import {Form, FormLabel} from '../../../../widgets/form';
import {ChartColumnProvider} from './chart_renderers';

interface ColumnInfo {
readonly name: string;
}

/**
* Pick the right measure column when the aggregation type changes.
* Returns undefined when no change is needed, or the new column name
* (or undefined to clear it).
*/
function pickMeasureColumn(
agg: ChartAggregation,
currentCol: string | undefined,
numericColumns: readonly ColumnInfo[],
allColumns: readonly ColumnInfo[],
): string | undefined {
if (agg === 'COUNT') {
// COUNT doesn't need a measure column.
return currentCol;
}
if (agg === 'COUNT_DISTINCT') {
// COUNT_DISTINCT works on any column; keep current or pick first.
return (
currentCol ?? (allColumns.length > 0 ? allColumns[0].name : undefined)
);
}
// Numeric aggregations: reset if current column isn't numeric.
const numericNames = new Set(numericColumns.map((c) => c.name));
if (currentCol && numericNames.has(currentCol)) {
return currentCol;
}
return numericColumns.length > 0 ? numericColumns[0].name : undefined;
}

export interface ChartConfigPopupContext {
readonly node: ChartColumnProvider;
readonly onFilterChange?: () => void;
Expand All @@ -48,7 +81,7 @@ export function renderChartConfigPopup(
const numericColumns = ctx.node.getChartableColumns('histogram');
const hasNumericColumns = numericColumns.length > 0;

// Bar shows measure column when aggregation is not COUNT.
// Show measure column when aggregation requires a column (not COUNT).
const showMeasureColumn =
def?.supportsAggregation &&
config.aggregation !== undefined &&
Expand Down Expand Up @@ -159,24 +192,31 @@ export function renderChartConfigPopup(
const target = e.target as HTMLSelectElement;
const agg = target.value as ChartAggregation;
const updates: Partial<ChartConfig> = {aggregation: agg};
// Auto-select first numeric column when switching to a
// numeric aggregation and no measure column is set yet.
if (
agg !== 'COUNT' &&
!config.measureColumn &&
numericColumns.length > 0
) {
updates.measureColumn = numericColumns[0].name;
const picked = pickMeasureColumn(
agg,
config.measureColumn,
numericColumns,
allColumns,
);
if (picked !== config.measureColumn) {
updates.measureColumn = picked;
}
ctx.node.updateChart(config.id, updates);
},
},
[
m('option', {value: 'COUNT'}, 'Count'),
m('option', {value: 'COUNT_DISTINCT'}, 'Count Distinct'),
m('option', {value: 'SUM', disabled: !hasNumericColumns}, 'Sum'),
m('option', {value: 'AVG', disabled: !hasNumericColumns}, 'Avg'),
m('option', {value: 'MIN', disabled: !hasNumericColumns}, 'Min'),
m('option', {value: 'MAX', disabled: !hasNumericColumns}, 'Max'),
m('option', {value: 'P25', disabled: !hasNumericColumns}, 'P25'),
m('option', {value: 'P50', disabled: !hasNumericColumns}, 'P50'),
m('option', {value: 'P75', disabled: !hasNumericColumns}, 'P75'),
m('option', {value: 'P90', disabled: !hasNumericColumns}, 'P90'),
m('option', {value: 'P95', disabled: !hasNumericColumns}, 'P95'),
m('option', {value: 'P99', disabled: !hasNumericColumns}, 'P99'),
],
),
]),
Expand All @@ -201,9 +241,11 @@ export function renderChartConfigPopup(
{value: '', disabled: true},
`Select ${measureColumnLabel.toLowerCase()}...`,
),
...numericColumns.map((col) =>
m('option', {value: col.name}, col.name),
),
// COUNT_DISTINCT works on any column type; others need numeric.
...(config.aggregation === 'COUNT_DISTINCT'
? allColumns
: numericColumns
).map((col) => m('option', {value: col.name}, col.name)),
],
),
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ import {EmptyState} from '../../../../widgets/empty_state';
import {SqlValue} from '../../../../trace_processor/query_result';
import {Engine} from '../../../../trace_processor/engine';
import {isIntegerColumn, getNumericFormatter} from './chart_column_formatters';
import {ChartAggregation} from '../../../../components/widgets/charts/chart_utils';

/**
* Formats a human-readable measure label for chart axes.
*/
function formatMeasureLabel(
agg: ChartAggregation,
config: ChartConfig,
): string {
switch (agg) {
case 'COUNT':
return 'Count';
case 'COUNT_DISTINCT':
// COUNT_DISTINCT uses config.column (not measureColumn) because
// it operates on the dimension column itself.
return `Count Distinct(${config.column})`;
default:
return `${agg}(${config.measureColumn ?? config.column})`;
}
}

/**
* Per-chart loader state.
Expand Down Expand Up @@ -268,21 +288,18 @@ export function renderBarChart(
const agg = config.aggregation ?? 'COUNT';
const {data} = entry.barLoader.use({aggregation: agg, limit: 100});

const measureLabel =
agg === 'COUNT'
? 'Count'
: `${agg}(${config.measureColumn ?? config.column})`;
const measureLabel = formatMeasureLabel(agg, config);

const dimFormatter = getNumericFormatter(ctx.node, config.column);
const formatDimension =
dimFormatter !== undefined
? (v: string | number) => (typeof v === 'number' ? dimFormatter(v) : v)
: undefined;

const formatMeasure =
agg !== 'COUNT'
? getNumericFormatter(ctx.node, config.measureColumn ?? config.column)
: undefined;
const needsMeasureFormatter = agg !== 'COUNT' && agg !== 'COUNT_DISTINCT';
const formatMeasure = needsMeasureFormatter
? getNumericFormatter(ctx.node, config.measureColumn ?? config.column)
: undefined;

return m(BarChart, {
data,
Expand Down Expand Up @@ -397,7 +414,7 @@ export function renderPieChart(
const {data} = entry.pieLoader.use({aggregation: agg, limit: 20});

const formatValue =
agg !== 'COUNT'
agg !== 'COUNT' && agg !== 'COUNT_DISTINCT'
? getNumericFormatter(ctx.node, config.measureColumn ?? config.column)
: undefined;

Expand Down
Loading
Loading