Flamegraph: Add collapsing for similar items in the stack (#77461)

This commit is contained in:
Andrej Ocenas 2023-11-09 15:31:07 +01:00 committed by GitHub
parent 6a5de14ed1
commit 494a07b522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 2303 additions and 28666 deletions

View File

@ -163,6 +163,7 @@ Experimental features might be changed or removed without prior notice.
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles |
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
## Development feature toggles

View File

@ -160,4 +160,5 @@ export interface FeatureToggles {
pdfTables?: boolean;
ssoSettingsApi?: boolean;
logsInfiniteScrolling?: boolean;
flameGraphItemCollapsing?: boolean;
}

View File

@ -25,7 +25,7 @@ jest.mock('react-use', () => {
describe('FlameGraph', () => {
function setup() {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData);
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
const setRangeMin = jest.fn();
const setRangeMax = jest.fn();

View File

@ -17,7 +17,7 @@
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
import { css, cx } from '@emotion/css';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Icon } from '@grafana/ui';
@ -26,7 +26,7 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ
import FlameGraphCanvas from './FlameGraphCanvas';
import FlameGraphMetadata from './FlameGraphMetadata';
import { FlameGraphDataContainer } from './dataTransform';
import { CollapsedMap, FlameGraphDataContainer } from './dataTransform';
type Props = {
data: FlameGraphDataContainer;
@ -44,6 +44,7 @@ type Props = {
onFocusPillClick: () => void;
onSandwichPillClick: () => void;
colorScheme: ColorScheme | ColorSchemeDiff;
collapsing?: boolean;
};
const FlameGraph = ({
@ -61,9 +62,17 @@ const FlameGraph = ({
onFocusPillClick,
onSandwichPillClick,
colorScheme,
collapsing,
}: Props) => {
const styles = getStyles();
const [collapsedMap, setCollapsedMap] = useState<CollapsedMap>(data ? data.getCollapsedMap() : new Map());
useEffect(() => {
if (data) {
setCollapsedMap(data.getCollapsedMap());
}
}, [data]);
const [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks] = useMemo(() => {
let levels = data.getLevels();
let totalProfileTicks = levels.length ? levels[0][0].value : 0;
@ -96,6 +105,9 @@ const FlameGraph = ({
totalProfileTicks,
totalProfileTicksRight,
totalViewTicks,
collapsedMap,
setCollapsedMap,
collapsing,
};
const canvas = levelsCallers ? (
<>
@ -109,6 +121,8 @@ const FlameGraph = ({
root={levelsCallers[levelsCallers.length - 1][0]}
depth={levelsCallers.length}
direction={'parents'}
// We do not support collapsing in sandwich view for now.
collapsing={false}
/>
</div>
@ -117,7 +131,13 @@ const FlameGraph = ({
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
</div>
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
<FlameGraphCanvas
{...commonCanvasProps}
root={levels[0][0]}
depth={levels.length}
direction={'children'}
collapsing={false}
/>
</div>
</>
) : (

View File

@ -0,0 +1,40 @@
import { convertPixelCoordinatesToBarCoordinates } from './FlameGraphCanvas';
import { textToDataContainer } from './testHelpers';
describe('convertPixelCoordinatesToBarCoordinates', () => {
const container = textToDataContainer(`
[0///////////]
[1][3//][4///]
[2] [5///]
[6]
`)!;
const root = container.getLevels()[0][0];
const testPosFn = (pos: { x: number; y: number }) => {
return convertPixelCoordinatesToBarCoordinates(
pos,
root,
'children',
container.getLevels().length,
1,
14,
0,
container.getCollapsedMap()
)!;
};
it('returns correct item', () => {
expect(testPosFn({ x: 4, y: 23 })!.itemIndexes[0]).toEqual(3);
});
it('returns no item when pointing to collapsed item', () => {
expect(testPosFn({ x: 1, y: 45 })).toBeUndefined();
});
it('returns item when pointing to first collapsed item', () => {
expect(testPosFn({ x: 1, y: 23 })!.itemIndexes[0]).toEqual(1);
});
it('returns correct shifted item because of collapsing', () => {
expect(testPosFn({ x: 9, y: 45 })!.itemIndexes[0]).toEqual(6);
});
});

View File

@ -7,7 +7,7 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ
import FlameGraphContextMenu from './FlameGraphContextMenu';
import FlameGraphTooltip from './FlameGraphTooltip';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getBarX, useFlameRender } from './rendering';
type Props = {
@ -32,6 +32,10 @@ type Props = {
totalProfileTicks: number;
totalProfileTicksRight?: number;
totalViewTicks: number;
collapsedMap: CollapsedMap;
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
collapsing?: boolean;
};
const FlameGraphCanvas = ({
@ -52,6 +56,9 @@ const FlameGraphCanvas = ({
root,
direction,
depth,
collapsedMap,
setCollapsedMap,
collapsing,
}: Props) => {
const styles = getStyles();
@ -78,6 +85,7 @@ const FlameGraphCanvas = ({
totalColorTicks: data.isDiffFlamegraph() ? totalProfileTicks : totalViewTicks,
totalTicksRight: totalProfileTicksRight,
wrapperWidth,
collapsedMap,
});
const onGraphClick = useCallback(
@ -91,7 +99,8 @@ const FlameGraphCanvas = ({
depth,
pixelsPerTick,
totalViewTicks,
rangeMin
rangeMin,
collapsedMap
);
// if clicking on a block in the canvas
@ -107,7 +116,7 @@ const FlameGraphCanvas = ({
setClickedItemData(undefined);
}
},
[data, rangeMin, rangeMax, totalViewTicks, root, direction, depth]
[data, rangeMin, rangeMax, totalViewTicks, root, direction, depth, collapsedMap]
);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>();
@ -124,7 +133,8 @@ const FlameGraphCanvas = ({
depth,
pixelsPerTick,
totalViewTicks,
rangeMin
rangeMin,
collapsedMap
);
if (item) {
@ -133,7 +143,7 @@ const FlameGraphCanvas = ({
}
}
},
[rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth]
[rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth, collapsedMap]
);
const onGraphMouseLeave = useCallback(() => {
@ -165,10 +175,18 @@ const FlameGraphCanvas = ({
onMouseLeave={onGraphMouseLeave}
/>
</div>
<FlameGraphTooltip position={mousePosition} item={tooltipItem} data={data} totalTicks={totalViewTicks} />
<FlameGraphTooltip
position={mousePosition}
item={tooltipItem}
data={data}
totalTicks={totalViewTicks}
collapseConfig={tooltipItem ? collapsedMap.get(tooltipItem) : undefined}
/>
{clickedItemData && (
<FlameGraphContextMenu
itemData={clickedItemData}
collapsing={collapsing}
collapseConfig={collapsedMap.get(clickedItemData.item)}
onMenuItemClick={() => {
setClickedItemData(undefined);
}}
@ -180,12 +198,47 @@ const FlameGraphCanvas = ({
onSandwich={() => {
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
}}
onExpandGroup={() => {
setCollapsedMap(setCollapsedStatus(collapsedMap, clickedItemData.item, false));
}}
onCollapseGroup={() => {
setCollapsedMap(setCollapsedStatus(collapsedMap, clickedItemData.item, true));
}}
onExpandAllGroups={() => {
setCollapsedMap(setAllCollapsedStatus(collapsedMap, false));
}}
onCollapseAllGroups={() => {
setCollapsedMap(setAllCollapsedStatus(collapsedMap, true));
}}
allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)}
allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)}
/>
)}
</div>
);
};
function setCollapsedStatus(collapsedMap: CollapsedMap, item: LevelItem, collapsed: boolean) {
const newMap = new Map(collapsedMap);
const collapsedConfig = collapsedMap.get(item)!;
const newConfig = { ...collapsedConfig, collapsed };
for (const item of collapsedConfig.items) {
newMap.set(item, newConfig);
}
return newMap;
}
function setAllCollapsedStatus(collapsedMap: CollapsedMap, collapsed: boolean) {
const newMap = new Map(collapsedMap);
for (const item of collapsedMap.keys()) {
const collapsedConfig = collapsedMap.get(item)!;
const newConfig = { ...collapsedConfig, collapsed };
newMap.set(item, newConfig);
}
return newMap;
}
const getStyles = () => ({
graph: css({
label: 'graph',
@ -217,7 +270,7 @@ const getStyles = () => ({
}),
});
const convertPixelCoordinatesToBarCoordinates = (
export const convertPixelCoordinatesToBarCoordinates = (
// position relative to the start of the graph
pos: { x: number; y: number },
root: LevelItem,
@ -225,7 +278,8 @@ const convertPixelCoordinatesToBarCoordinates = (
depth: number,
pixelsPerTick: number,
totalTicks: number,
rangeMin: number
rangeMin: number,
collapsedMap: Map<LevelItem, CollapseConfig>
): LevelItem | undefined => {
let next: LevelItem | undefined = root;
let currentLevel = direction === 'children' ? 0 : depth - 1;
@ -247,7 +301,13 @@ const convertPixelCoordinatesToBarCoordinates = (
const xEnd = getBarX(child.start + child.value, totalTicks, rangeMin, pixelsPerTick);
if (xStart <= pos.x && pos.x < xEnd) {
next = child;
currentLevel = currentLevel + (direction === 'children' ? 1 : -1);
// Check if item is a collapsed item. if so also check if the item is the first collapsed item in the chain,
// which we render, or a child which we don't render. If it's a child in the chain then don't increase the
// level end effectively skip it.
const collapsedConfig = collapsedMap.get(child);
if (!collapsedConfig || !collapsedConfig.collapsed || collapsedConfig.items[0] === child) {
currentLevel = currentLevel + (direction === 'children' ? 1 : -1);
}
break;
}
}

View File

@ -1,17 +1,40 @@
import React from 'react';
import { MenuItem, ContextMenu } from '@grafana/ui';
import { MenuItem, MenuGroup, ContextMenu } from '@grafana/ui';
import { ClickedItemData } from '../types';
import { CollapseConfig } from './dataTransform';
type Props = {
itemData: ClickedItemData;
onMenuItemClick: () => void;
onItemFocus: () => void;
onSandwich: () => void;
onExpandGroup: () => void;
onCollapseGroup: () => void;
onExpandAllGroups: () => void;
onCollapseAllGroups: () => void;
collapseConfig?: CollapseConfig;
collapsing?: boolean;
allGroupsCollapsed?: boolean;
allGroupsExpanded?: boolean;
};
const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandwich }: Props) => {
const FlameGraphContextMenu = ({
itemData,
onMenuItemClick,
onItemFocus,
onSandwich,
collapseConfig,
onExpandGroup,
onCollapseGroup,
onExpandAllGroups,
onCollapseAllGroups,
collapsing,
allGroupsExpanded,
allGroupsCollapsed,
}: Props) => {
function renderItems() {
return (
<>
@ -40,6 +63,52 @@ const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandw
onMenuItemClick();
}}
/>
{collapsing && (
<MenuGroup label={'Grouping'}>
{collapseConfig ? (
collapseConfig.collapsed ? (
<MenuItem
label="Expand group"
icon={'angle-double-down'}
onClick={() => {
onExpandGroup();
onMenuItemClick();
}}
/>
) : (
<MenuItem
label="Collapse group"
icon={'angle-double-up'}
onClick={() => {
onCollapseGroup();
onMenuItemClick();
}}
/>
)
) : null}
{!allGroupsExpanded && (
<MenuItem
label="Expand all groups"
icon={'angle-double-down'}
onClick={() => {
onExpandAllGroups();
onMenuItemClick();
}}
/>
)}
{!allGroupsCollapsed && (
<MenuItem
label="Collapse all groups"
icon={'angle-double-up'}
onClick={() => {
onCollapseAllGroups();
onMenuItemClick();
}}
/>
)}
</MenuGroup>
)}
</>
);
}

View File

@ -12,7 +12,7 @@ function setupData(unit?: string) {
{ name: 'label', values: ['total'] },
],
});
return new FlameGraphDataContainer(flameGraphData);
return new FlameGraphDataContainer(flameGraphData, { collapsing: true });
}
function setupDiffData() {
@ -26,7 +26,7 @@ function setupDiffData() {
{ name: 'label', values: ['total', 'func1'] },
],
});
return new FlameGraphDataContainer(flameGraphData);
return new FlameGraphDataContainer(flameGraphData, { collapsing: true });
}
describe('FlameGraphTooltip', () => {

View File

@ -4,16 +4,17 @@ import React from 'react';
import { DisplayValue, getValueFormat, GrafanaTheme2 } from '@grafana/data';
import { InteractiveTable, Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { CollapseConfig, FlameGraphDataContainer, LevelItem } from './dataTransform';
type Props = {
data: FlameGraphDataContainer;
totalTicks: number;
position?: { x: number; y: number };
item?: LevelItem;
collapseConfig?: CollapseConfig;
};
const FlameGraphTooltip = ({ data, item, totalTicks, position }: Props) => {
const FlameGraphTooltip = ({ data, item, totalTicks, position, collapseConfig }: Props) => {
const styles = useStyles2(getStyles);
if (!(item && position)) {
@ -56,7 +57,17 @@ const FlameGraphTooltip = ({ data, item, totalTicks, position }: Props) => {
<Portal>
<VizTooltipContainer className={styles.tooltipContainer} position={position} offset={{ x: 15, y: 0 }}>
<div className={styles.tooltipContent}>
<p className={styles.tooltipName}>{data.getLabel(item.itemIndexes[0])}</p>
<p className={styles.tooltipName}>
{data.getLabel(item.itemIndexes[0])}
{collapseConfig && collapseConfig.collapsed ? (
<span>
<br />
and {collapseConfig.items.length} similar items
</span>
) : (
''
)}
</p>
{content}
</div>
</VizTooltipContainer>

View File

@ -1,6 +1,7 @@
import { createDataFrame, FieldType } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform';
import { textToDataContainer } from './testHelpers';
describe('nestedSetToLevels', () => {
it('converts nested set data frame to levels', () => {
@ -17,7 +18,7 @@ describe('nestedSetToLevels', () => {
{ name: 'self', values: [0, 0, 0, 0, 0, 0, 0, 0, 0] },
],
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame, { collapsing: true }));
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1, level: 4 };
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2, level: 3 };
@ -54,7 +55,7 @@ describe('nestedSetToLevels', () => {
{ name: 'self', values: [10, 5, 3, 1] },
],
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame, { collapsing: true }));
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1, level: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3, level: 1 };
@ -69,3 +70,34 @@ describe('nestedSetToLevels', () => {
expect(levels[1]).toEqual([n2, n3, n4]);
});
});
describe('FlameGraphDataContainer', () => {
it('creates correct collapse map', () => {
const container = textToDataContainer(`
[0//////////////]
[1][3//][6///]
[2][4] [7///]
[5] [8///]
[9///]
`)!;
const collapsedMap = container.getCollapsedMap();
expect(Array.from(collapsedMap.keys()).map((item) => item.itemIndexes[0])).toEqual([1, 2, 4, 5, 6, 7, 8, 9]);
expect(Array.from(collapsedMap.values())[0]).toMatchObject({
collapsed: true,
items: [{ itemIndexes: [1] }, { itemIndexes: [2] }],
});
});
it('creates empty collapse map if no items are similar', () => {
const container = textToDataContainer(`
[0//////////////]
[1][3//][6///]
[9/]
`)!;
const collapsedMap = container.getCollapsedMap();
expect(Array.from(collapsedMap.keys()).length).toEqual(0);
});
});

View File

@ -40,12 +40,18 @@ export type CollapsedMap = Map<LevelItem, CollapseConfig>;
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
* rendering code.
*/
export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelItem[][], Record<string, LevelItem[]>] {
export function nestedSetToLevels(
container: FlameGraphDataContainer,
options?: {
collapsing: boolean;
}
): [LevelItem[][], Record<string, LevelItem[]>, CollapsedMap] {
const levels: LevelItem[][] = [];
let offset = 0;
let parent: LevelItem | undefined = undefined;
const uniqueLabels: Record<string, LevelItem[]> = {};
const collapsedMap: CollapsedMap = new Map();
for (let i = 0; i < container.data.length; i++) {
const currentLevel = container.getLevel(i);
@ -76,6 +82,22 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte
level: currentLevel,
};
if (options?.collapsing) {
// We collapse similar items here, where it seems like parent and child are the same thing and so the distinction
// isn't that important. We create a map of items that should be collapsed together.
if (parent && newItem.value === parent.value) {
if (collapsedMap.has(parent)) {
const config = collapsedMap.get(parent)!;
collapsedMap.set(newItem, config);
config.items.push(newItem);
} else {
const config = { items: [parent, newItem], collapsed: true };
collapsedMap.set(parent, config);
collapsedMap.set(newItem, config);
}
}
}
if (uniqueLabels[container.getLabel(i)]) {
uniqueLabels[container.getLabel(i)].push(newItem);
} else {
@ -90,7 +112,7 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte
levels[currentLevel].push(newItem);
}
return [levels, uniqueLabels];
return [levels, uniqueLabels, collapsedMap];
}
export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) {
@ -146,6 +168,8 @@ export function checkFields(data: DataFrame): CheckFieldsResult | undefined {
export class FlameGraphDataContainer {
data: DataFrame;
options: { collapsing: boolean };
labelField: Field;
levelField: Field;
valueField: Field;
@ -161,9 +185,11 @@ export class FlameGraphDataContainer {
private levels: LevelItem[][] | undefined;
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
private collapsedMap: Map<LevelItem, CollapseConfig> | undefined;
constructor(data: DataFrame, theme: GrafanaTheme2 = createTheme()) {
constructor(data: DataFrame, options: { collapsing: boolean }, theme: GrafanaTheme2 = createTheme()) {
this.data = data;
this.options = options;
const wrongFields = checkFields(data);
if (wrongFields) {
@ -275,11 +301,17 @@ export class FlameGraphDataContainer {
return this.uniqueLabelsMap![label];
}
getCollapsedMap() {
this.initLevels();
return this.collapsedMap!;
}
private initLevels() {
if (!this.levels) {
const [levels, uniqueLabelsMap] = nestedSetToLevels(this);
const [levels, uniqueLabelsMap, collapsedMap] = nestedSetToLevels(this, this.options);
this.levels = levels;
this.uniqueLabelsMap = uniqueLabelsMap;
this.collapsedMap = collapsedMap;
}
}
}

View File

@ -2,6 +2,7 @@ import { createDataFrame, FieldType } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { walkTree } from './rendering';
import { textToDataContainer } from './testHelpers';
function makeDataFrame(fields: Record<string, Array<number | string>>) {
return createDataFrame({
@ -20,22 +21,36 @@ type RenderData = {
width: number;
height: number;
label: string;
collapsed: boolean;
muted: boolean;
};
describe('walkTree', () => {
it('correctly compute sizes for a single item', () => {
const root: LevelItem = { start: 0, itemIndexes: [0], children: [], value: 100, level: 0 };
const container = new FlameGraphDataContainer(makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }));
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
expect(item).toEqual(root);
expect(x).toEqual(0);
expect(y).toEqual(0);
expect(width).toEqual(99); // -1 for border
expect(height).toEqual(22);
expect(label).toEqual('1');
expect(collapsed).toEqual(false);
});
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }),
{ collapsing: true }
);
walkTree(
root,
'children',
container,
100,
0,
1,
100,
container.getCollapsedMap(),
(item, x, y, width, height, label, collapsed) => {
expect(item).toEqual(root);
expect(x).toEqual(0);
expect(y).toEqual(0);
expect(width).toEqual(99); // -1 for border
expect(height).toEqual(22);
expect(label).toEqual('1');
expect(collapsed).toEqual(false);
}
);
});
it('should render a multiple items', () => {
@ -50,20 +65,31 @@ describe('walkTree', () => {
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 50, 50], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 50, 50] })
makeDataFrame({ value: [100, 50, 50], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 50, 50] }),
{ collapsing: true }
);
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
walkTree(
root,
'children',
container,
100,
0,
1,
100,
container.getCollapsedMap(),
(item, x, y, width, height, label, muted) => {
renderData.push({ item, x, y, width, height, label, muted });
}
);
expect(renderData).toEqual([
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
{ item: root.children[0], width: 49, height: 22, x: 0, y: 22, collapsed: false, label: '2' },
{ item: root.children[1], width: 49, height: 22, x: 50, y: 22, collapsed: false, label: '3' },
{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' },
{ item: root.children[0], width: 49, height: 22, x: 0, y: 22, muted: false, label: '2' },
{ item: root.children[1], width: 49, height: 22, x: 50, y: 22, muted: false, label: '3' },
]);
});
it('should render a collapsed items', () => {
it('should render a muted items', () => {
const root: LevelItem = {
start: 0,
itemIndexes: [0],
@ -75,16 +101,27 @@ describe('walkTree', () => {
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 1, 1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 1, 1] })
makeDataFrame({ value: [100, 1, 1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 1, 1] }),
{ collapsing: true }
);
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
walkTree(
root,
'children',
container,
100,
0,
1,
100,
container.getCollapsedMap(),
(item, x, y, width, height, label, muted) => {
renderData.push({ item, x, y, width, height, label, muted });
}
);
expect(renderData).toEqual([
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
{ item: root.children[0], width: 1, height: 22, x: 0, y: 22, collapsed: true, label: '2' },
{ item: root.children[1], width: 1, height: 22, x: 1, y: 22, collapsed: true, label: '3' },
{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' },
{ item: root.children[0], width: 1, height: 22, x: 0, y: 22, muted: true, label: '2' },
{ item: root.children[1], width: 1, height: 22, x: 1, y: 22, muted: true, label: '3' },
]);
});
@ -100,12 +137,54 @@ describe('walkTree', () => {
],
};
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 0.1, 0.1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 0.1, 0.1] })
makeDataFrame({ value: [100, 0.1, 0.1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 0.1, 0.1] }),
{ collapsing: true }
);
const renderData: RenderData[] = [];
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
renderData.push({ item, x, y, width, height, label, collapsed });
});
expect(renderData).toEqual([{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' }]);
walkTree(
root,
'children',
container,
100,
0,
1,
100,
container.getCollapsedMap(),
(item, x, y, width, height, label, muted) => {
renderData.push({ item, x, y, width, height, label, muted });
}
);
expect(renderData).toEqual([{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' }]);
});
it('should correctly skip a collapsed items', () => {
const container = textToDataContainer(`
[0///////////]
[1][3//][4///]
[2] [5///]
`)!;
const root = container.getLevels()[0][0];
const renderData: RenderData[] = [];
walkTree(
root,
'children',
container,
14,
0,
1,
14,
container.getCollapsedMap(),
(item, x, y, width, height, label, muted) => {
renderData.push({ item, x, y, width, height, label, muted });
}
);
expect(renderData).toEqual([
{ item: root, width: 13, height: 22, x: 0, y: 0, muted: false, label: '0' },
{ item: root.children[0], width: 3, height: 22, x: 0, y: 22, muted: true, label: '1' },
{ item: root.children[1], width: 5, height: 22, x: 3, y: 22, muted: true, label: '3' },
{ item: root.children[2], width: 6, height: 22, x: 8, y: 22, muted: true, label: '4' },
]);
});
});

View File

@ -8,7 +8,7 @@ import { useTheme2 } from '@grafana/ui';
import {
BAR_BORDER_WIDTH,
BAR_TEXT_PADDING_LEFT,
COLLAPSE_THRESHOLD,
MUTE_THRESHOLD,
HIDE_THRESHOLD,
LABEL_THRESHOLD,
PIXELS_PER_LEVEL,
@ -16,7 +16,7 @@ import {
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types';
import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './colors';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
const ufuzzy = new uFuzzy();
@ -46,6 +46,7 @@ type RenderOptions = {
totalTicksRight: number | undefined;
colorScheme: ColorScheme | ColorSchemeDiff;
focusedItemData?: ClickedItemData;
collapsedMap: CollapsedMap;
};
export function useFlameRender(options: RenderOptions) {
@ -65,6 +66,7 @@ export function useFlameRender(options: RenderOptions) {
totalTicksRight,
colorScheme,
focusedItemData,
collapsedMap,
} = options;
const foundLabels = useFoundLabels(search, data);
const ctx = useSetupCanvas(canvasRef, wrapperWidth, depth);
@ -83,7 +85,7 @@ export function useFlameRender(options: RenderOptions) {
foundLabels,
focusedItemData ? focusedItemData.item.level : 0
);
const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign);
const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign, collapsedMap);
useEffect(() => {
if (!ctx) {
@ -91,8 +93,8 @@ export function useFlameRender(options: RenderOptions) {
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
walkTree(root, direction, data, totalViewTicks, rangeMin, rangeMax, wrapperWidth, renderFunc);
}, [ctx, data, root, wrapperWidth, rangeMin, rangeMax, totalViewTicks, direction, renderFunc]);
walkTree(root, direction, data, totalViewTicks, rangeMin, rangeMax, wrapperWidth, collapsedMap, renderFunc);
}, [ctx, data, root, wrapperWidth, rangeMin, rangeMax, totalViewTicks, direction, renderFunc, collapsedMap]);
}
type RenderFunc = (
@ -102,39 +104,112 @@ type RenderFunc = (
width: number,
height: number,
label: string,
// Collapsed means the width is too small to show the label, and we group collapsed siblings together.
collapsed: boolean
// muted means the width is too small, and we just show gray rectangle.
muted: boolean
) => void;
function useRenderFunc(
ctx: CanvasRenderingContext2D | undefined,
data: FlameGraphDataContainer,
getBarColor: (item: LevelItem, label: string, collapsed: boolean) => string,
textAlign: TextAlign
getBarColor: (item: LevelItem, label: string, muted: boolean) => string,
textAlign: TextAlign,
collapsedMap: CollapsedMap
): RenderFunc {
return useMemo(() => {
if (!ctx) {
return () => {};
}
return (item, x, y, width, height, label, collapsed) => {
return (item, x, y, width, height, label, muted) => {
ctx.beginPath();
ctx.rect(x + (collapsed ? 0 : BAR_BORDER_WIDTH), y, width, height);
ctx.fillStyle = getBarColor(item, label, collapsed);
ctx.rect(x + (muted ? 0 : BAR_BORDER_WIDTH), y, width, height);
ctx.fillStyle = getBarColor(item, label, muted);
if (collapsed) {
// Only fill the collapsed rects
const collapsedItemConfig = collapsedMap.get(item);
if (collapsedItemConfig && collapsedItemConfig.collapsed) {
const numberOfCollapsedItems = collapsedItemConfig.items.length;
label = `(${numberOfCollapsedItems}) ` + label;
}
if (muted) {
// Only fill the muted rects
ctx.fill();
} else {
ctx.stroke();
ctx.fill();
if (width >= LABEL_THRESHOLD) {
renderLabel(ctx, data, label, item, width, x, y, textAlign);
if (collapsedItemConfig) {
if (width >= LABEL_THRESHOLD) {
renderLabel(
ctx,
data,
label,
item,
width,
textAlign === 'left' ? x + groupStripMarginLeft + 4 : x,
y,
textAlign
);
renderGroupingStrip(ctx, x, y, height, item, collapsedItemConfig);
}
} else {
if (width >= LABEL_THRESHOLD) {
renderLabel(ctx, data, label, item, width, x, y, textAlign);
}
}
}
};
}, [ctx, getBarColor, textAlign, data]);
}, [ctx, getBarColor, textAlign, data, collapsedMap]);
}
const groupStripMarginLeft = 8;
/**
* Render small strip on the left side of the bar to indicate that this item is part of a group that can be collapsed.
* @param ctx
* @param x
* @param y
* @param height
* @param item
* @param collapsedItemConfig
*/
function renderGroupingStrip(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
height: number,
item: LevelItem,
collapsedItemConfig: CollapseConfig
) {
const groupStripX = x + groupStripMarginLeft;
const groupStripWidth = 6;
// This is to mask the label in case we align it right to left.
ctx.beginPath();
ctx.rect(x, y, groupStripX - x + groupStripWidth + 6, height);
ctx.fill();
// For item in a group that can be collapsed, we draw a small strip to mark them. On the items that are at the
// start or and end of a group we draw just half the strip so 2 groups next to each other are separated
// visually.
ctx.beginPath();
if (collapsedItemConfig.collapsed) {
ctx.rect(groupStripX, y + height / 4, groupStripWidth, height / 2);
} else {
if (collapsedItemConfig.items[0] === item) {
// Top item
ctx.rect(groupStripX, y + height / 2, groupStripWidth, height / 2);
} else if (collapsedItemConfig.items[collapsedItemConfig.items.length - 1] === item) {
// Bottom item
ctx.rect(groupStripX, y, groupStripWidth, height / 2);
} else {
ctx.rect(groupStripX, y, groupStripWidth, height);
}
}
ctx.fillStyle = '#666';
ctx.fill();
}
/**
@ -151,19 +226,22 @@ export function walkTree(
rangeMin: number,
rangeMax: number,
wrapperWidth: number,
collapsedMap: CollapsedMap,
renderFunc: RenderFunc
) {
const stack: LevelItem[] = [];
stack.push(root);
// The levelOffset here is to keep track if items that we don't render because they are collapsed into single row.
// That means we have to render next items with an offset of some rows up in the stack.
const stack: Array<{ item: LevelItem; levelOffset: number }> = [];
stack.push({ item: root, levelOffset: 0 });
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalViewTicks / (rangeMax - rangeMin);
let collapsedItemRendered: LevelItem | undefined = undefined;
while (stack.length > 0) {
const item = stack.shift()!;
const { item, levelOffset } = stack.shift()!;
let curBarTicks = item.value;
// Multiple collapsed items are shown as a single gray bar
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
const muted = curBarTicks * pixelsPerTick <= MUTE_THRESHOLD;
const width = curBarTicks * pixelsPerTick - (muted ? 0 : BAR_BORDER_WIDTH * 2);
const height = PIXELS_PER_LEVEL;
if (width < HIDE_THRESHOLD) {
@ -171,16 +249,39 @@ export function walkTree(
continue;
}
const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick);
const barY = item.level * PIXELS_PER_LEVEL;
let offsetModifier = 0;
let skipRender = false;
const collapsedItemConfig = collapsedMap.get(item);
const isCollapsedItem = collapsedItemConfig && collapsedItemConfig.collapsed;
let label = data.getLabel(item.itemIndexes[0]);
if (isCollapsedItem) {
if (collapsedItemRendered === collapsedItemConfig.items[0]) {
offsetModifier = direction === 'children' ? -1 : +1;
skipRender = true;
} else {
// This is a case where we have another collapsed group right after different collapsed group, so we need to
// reset.
collapsedItemRendered = undefined;
}
} else {
collapsedItemRendered = undefined;
}
renderFunc(item, barX, barY, width, height, label, collapsed);
if (!skipRender) {
const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick);
const barY = (item.level + levelOffset) * PIXELS_PER_LEVEL;
let label = data.getLabel(item.itemIndexes[0]);
if (isCollapsedItem) {
collapsedItemRendered = item;
}
renderFunc(item, barX, barY, width, height, label, muted);
}
const nextList = direction === 'children' ? item.children : item.parents;
if (nextList) {
stack.unshift(...nextList);
stack.unshift(...nextList.map((c) => ({ item: c, levelOffset: levelOffset + offsetModifier })));
}
}
}
@ -225,9 +326,9 @@ function useColorFunction(
? barMutedColor.darken(10).toHexString()
: barMutedColor.lighten(10).toHexString();
return function getColor(item: LevelItem, label: string, collapsed: boolean) {
return function getColor(item: LevelItem, label: string, muted: boolean) {
// If collapsed and no search we can quickly return the muted color
if (collapsed && !foundNames) {
if (muted && !foundNames) {
// Collapsed are always grayed
return barMutedColorHex;
}
@ -312,7 +413,7 @@ function renderLabel(
}
}
ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2);
ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2 + 2);
ctx.restore();
}

View File

@ -80,7 +80,7 @@ export function textToDataContainer(text: string) {
const df = arrayToDataFrame(dfSorted);
const labelField = df.fields.find((f) => f.name === 'label')!;
labelField.type = FieldType.string;
return new FlameGraphDataContainer(df);
return new FlameGraphDataContainer(df, { collapsing: true });
}
export function trimLevelsString(s: string) {

View File

@ -53,6 +53,11 @@ export type Props = {
* If true the flamegraph will be rendered on top of the table.
*/
vertical?: boolean;
/**
* Disable behaviour where similar items in the same stack will be collapsed into single item.
*/
disableCollapsing?: boolean;
};
const FlameGraphContainer = ({
@ -65,6 +70,7 @@ const FlameGraphContainer = ({
stickyHeader,
extraHeaderElements,
vertical,
disableCollapsing,
}: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
@ -83,8 +89,8 @@ const FlameGraphContainer = ({
if (!data) {
return;
}
return new FlameGraphDataContainer(data, theme);
}, [data, theme]);
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
}, [data, theme, disableCollapsing]);
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = getStyles(theme, vertical);
@ -198,6 +204,7 @@ const FlameGraphContainer = ({
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
collapsing={!disableCollapsing}
/>
)}
</div>

View File

@ -12,7 +12,7 @@ import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
describe('FlameGraphTopTableContainer', () => {
const setup = () => {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData);
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
const onSearch = jest.fn();
const onSandwich = jest.fn();

View File

@ -1,5 +1,5 @@
export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio;
export const COLLAPSE_THRESHOLD = 10 * window.devicePixelRatio;
export const MUTE_THRESHOLD = 10 * window.devicePixelRatio;
export const HIDE_THRESHOLD = 0.5 * window.devicePixelRatio;
export const LABEL_THRESHOLD = 20 * window.devicePixelRatio;
export const BAR_BORDER_WIDTH = 0.5 * window.devicePixelRatio;

View File

@ -1032,6 +1032,13 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "flameGraphItemCollapsing",
Description: "Allow collapsing of flame graph items",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityTracesAndProfilingSquad,
},
}
)

View File

@ -141,3 +141,4 @@ panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,tru
pdfTables,privatePreview,@grafana/sharing-squad,false,false,false,false
ssoSettingsApi,experimental,@grafana/identity-access-team,true,false,false,false
logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,false,true
flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
141 pdfTables privatePreview @grafana/sharing-squad false false false false
142 ssoSettingsApi experimental @grafana/identity-access-team true false false false
143 logsInfiniteScrolling experimental @grafana/observability-logs false false false true
144 flameGraphItemCollapsing experimental @grafana/observability-traces-and-profiling false false false true

View File

@ -574,4 +574,8 @@ const (
// FlagLogsInfiniteScrolling
// Enables infinite scrolling for the Logs panel in Explore and Dashboards
FlagLogsInfiniteScrolling = "logsInfiniteScrolling"
// FlagFlameGraphItemCollapsing
// Allow collapsing of flame graph items
FlagFlameGraphItemCollapsing = "flameGraphItemCollapsing"
)

View File

@ -31,6 +31,7 @@ export const FlameGraphExploreContainer = (props: Props) => {
onViewSelected={(view: string) => interaction('view_selected', { view })}
onTextAlignSelected={(align: string) => interaction('text_align_selected', { align })}
onTableSort={(sort: string) => interaction('table_sort_selected', { sort })}
disableCollapsing={!config.featureToggles.flameGraphItemCollapsing}
/>
</div>
);

View File

@ -29,6 +29,7 @@ export const FlameGraphPanel = (props: PanelProps) => {
onViewSelected={(view: string) => interaction('view_selected', { view })}
onTextAlignSelected={(align: string) => interaction('text_align_selected', { align })}
onTableSort={(sort: string) => interaction('table_sort_selected', { sort })}
disableCollapsing={!config.featureToggles.flameGraphItemCollapsing}
/>
);
};