FlameGraph: Add sandwich view (#70268)

This commit is contained in:
Andrej Ocenas 2023-06-19 16:34:06 +02:00 committed by GitHub
parent db44ba305e
commit 5ca03a82f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 43623 additions and 2 deletions

View File

@ -4922,6 +4922,9 @@ exports[`better eslint`] = {
"public/app/plugins/panel/flamegraph/components/FlameGraphTopWrapper.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/flamegraph/flamegraphV2/components/FlameGraphTopWrapper.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/gauge/GaugeMigrations.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -115,6 +115,7 @@ Experimental features might be changed or removed without prior notice.
| `exploreScrollableLogsContainer` | Improves the scrolling behavior of logs in Explore |
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries |
| `alertingLokiRangeToInstant` | Rewrites eligible loki range queries to instant queries |
| `flameGraphV2` | New version of flame graph with new features |
## Development feature toggles

View File

@ -102,4 +102,5 @@ export interface FeatureToggles {
exploreScrollableLogsContainer?: boolean;
recordedQueriesMulti?: boolean;
alertingLokiRangeToInstant?: boolean;
flameGraphV2?: boolean;
}

View File

@ -571,5 +571,12 @@ var (
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
{
Name: "flameGraphV2",
Description: "New version of flame graph with new features",
FrontendOnly: true,
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityTracesAndProfilingSquad,
},
}
)

View File

@ -83,3 +83,4 @@ cloudWatchLogsMonacoEditor,experimental,@grafana/aws-plugins,false,false,false,t
exploreScrollableLogsContainer,experimental,@grafana/observability-logs,false,false,false,true
recordedQueriesMulti,experimental,@grafana/observability-metrics,false,false,false,false
alertingLokiRangeToInstant,experimental,@grafana/alerting-squad,false,false,false,false
flameGraphV2,experimental,@grafana/observability-traces-and-profiling,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
83 exploreScrollableLogsContainer experimental @grafana/observability-logs false false false true
84 recordedQueriesMulti experimental @grafana/observability-metrics false false false false
85 alertingLokiRangeToInstant experimental @grafana/alerting-squad false false false false
86 flameGraphV2 experimental @grafana/observability-traces-and-profiling false false false true

View File

@ -342,4 +342,8 @@ const (
// FlagAlertingLokiRangeToInstant
// Rewrites eligible loki range queries to instant queries
FlagAlertingLokiRangeToInstant = "alertingLokiRangeToInstant"
// FlagFlameGraphV2
// New version of flame graph with new features
FlagFlameGraphV2 = "flameGraphV2"
)

View File

@ -1,9 +1,15 @@
import React from 'react';
import { CoreApp, PanelProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import FlameGraphContainer from './components/FlameGraphContainer';
import FlameGraphContainerV2 from './flamegraphV2/components/FlameGraphContainer';
export const FlameGraphPanel = (props: PanelProps) => {
return <FlameGraphContainer data={props.data.series[0]} app={CoreApp.Unknown} />;
return config.featureToggles.flameGraphV2 ? (
<FlameGraphContainerV2 data={props.data.series[0]} app={CoreApp.Unknown} />
) : (
<FlameGraphContainer data={props.data.series[0]} app={CoreApp.Unknown} />
);
};

View File

@ -5,6 +5,8 @@ import '@pyroscope/flamegraph/dist/index.css';
import { CoreApp, DataFrame, DataFrameView } from '@grafana/data';
import { config } from '@grafana/runtime';
import FlameGraphContainerV2 from '../flamegraphV2/components/FlameGraphContainer';
import FlameGraphContainer from './FlameGraphContainer';
type Props = {
@ -22,7 +24,11 @@ export const FlameGraphTopWrapper = (props: Props) => {
return <FlamegraphRenderer profile={profile} />;
}
return <FlameGraphContainer data={props.data} app={props.app} />;
return config.featureToggles.flameGraphV2 ? (
<FlameGraphContainerV2 data={props.data} app={props.app} />
) : (
<FlameGraphContainer data={props.data} app={props.app} />
);
};
type Row = {

View File

@ -0,0 +1,95 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { createDataFrame } from '@grafana/data';
import FlameGraph from './FlameGraph';
import { FlameGraphDataContainer } from './dataTransform';
import { data } from './testData/dataNestedSet';
import 'jest-canvas-mock';
jest.mock('react-use', () => {
const reactUse = jest.requireActual('react-use');
return {
...reactUse,
useMeasure: () => {
const ref = React.useRef();
return [ref, { width: 1600 }];
},
};
});
describe('FlameGraph', () => {
function setup() {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData);
const setRangeMin = jest.fn();
const setRangeMax = jest.fn();
const onItemFocused = jest.fn();
const onSandwich = jest.fn();
const onFocusPillClick = jest.fn();
const onSandwichPillClick = jest.fn();
const renderResult = render(
<FlameGraph
data={container}
rangeMin={0}
rangeMax={1}
search={''}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={onItemFocused}
textAlign={'left'}
onSandwich={onSandwich}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
);
return {
renderResult,
mocks: {
setRangeMax,
setRangeMin,
onItemFocused,
onSandwich,
onFocusPillClick,
onSandwichPillClick,
},
};
}
it('should render without error', async () => {
setup();
});
it('should render correctly', async () => {
setup();
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
const ctx = canvas!.getContext('2d');
const calls = ctx!.__getDrawCalls();
expect(calls).toMatchSnapshot();
});
it('should render metadata', async () => {
setup();
expect(screen.getByText('16.5 Bil | 16.5 Bil samples (Count)')).toBeDefined();
});
it('should render context menu', async () => {
const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'offsetX', { get: () => 10 });
Object.defineProperty(event, 'offsetY', { get: () => 10 });
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 });
setup();
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
expect(canvas).toBeInTheDocument();
expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument();
fireEvent(canvas, event);
expect(screen.getByTestId('contextMenu')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,311 @@
// This component is based on logic from the flamebearer project
// https://github.com/mapbox/flamebearer
// ISC License
// Copyright (c) 2018, Mapbox
// Permission to use, copy, modify, and/or distribute this software for any purpose
// with or without fee is hereby granted, provided that the above copyright notice
// and this permission notice appear in all copies.
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
import { css } from '@emotion/css';
import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMeasure } from 'react-use';
import { Icon, useStyles2 } from '@grafana/ui';
import { PIXELS_PER_LEVEL } from '../../constants';
import { ClickedItemData, TextAlign } from '../types';
import FlameGraphContextMenu from './FlameGraphContextMenu';
import FlameGraphMetadata from './FlameGraphMetadata';
import FlameGraphTooltip from './FlameGraphTooltip';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getBarX, useFlameRender } from './rendering';
type Props = {
data: FlameGraphDataContainer;
rangeMin: number;
rangeMax: number;
search: string;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
style?: React.CSSProperties;
onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData;
textAlign: TextAlign;
sandwichItem?: string;
onSandwich: (label: string) => void;
onFocusPillClick: () => void;
onSandwichPillClick: () => void;
};
const FlameGraph = ({
data,
rangeMin,
rangeMax,
search,
setRangeMin,
setRangeMax,
onItemFocused,
focusedItemData,
textAlign,
onSandwich,
sandwichItem,
onFocusPillClick,
onSandwichPillClick,
}: Props) => {
const styles = useStyles2(getStyles);
const [levels, totalTicks, callersCount] = useMemo(() => {
let levels = data.getLevels();
let totalTicks = levels.length ? levels[0][0].value : 0;
let callersCount = 0;
if (sandwichItem) {
const [callers, callees] = data.getSandwichLevels(sandwichItem);
levels = [...callers, [], ...callees];
totalTicks = callees.length ? callees[0][0].value : 0;
callersCount = callers.length;
}
return [levels, totalTicks, callersCount];
}, [data, sandwichItem]);
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
const graphRef = useRef<HTMLCanvasElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipItem, setTooltipItem] = useState<LevelItem>();
const [clickedItemData, setClickedItemData] = useState<ClickedItemData>();
useFlameRender(
graphRef,
data,
levels,
wrapperWidth,
rangeMin,
rangeMax,
search,
textAlign,
totalTicks,
focusedItemData
);
const onGraphClick = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
setTooltipItem(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
levels,
pixelsPerTick,
totalTicks,
rangeMin
);
// if clicking on a block in the canvas
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
const item = levels[levelIndex][barIndex];
setClickedItemData({
posY: e.clientY,
posX: e.clientX,
item,
level: levelIndex,
label: data.getLabel(item.itemIndexes[0]),
});
} else {
// if clicking on the canvas but there is no block beneath the cursor
setClickedItemData(undefined);
}
},
[data, rangeMin, rangeMax, totalTicks, levels]
);
const onGraphMouseMove = useCallback(
(e: ReactMouseEvent<HTMLCanvasElement>) => {
if (tooltipRef.current && clickedItemData === undefined) {
setTooltipItem(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(
{ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY },
levels,
pixelsPerTick,
totalTicks,
rangeMin
);
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
tooltipRef.current.style.top = e.clientY + 'px';
if (document.documentElement.clientWidth - e.clientX < 400) {
tooltipRef.current.style.right = document.documentElement.clientWidth - e.clientX + 15 + 'px';
tooltipRef.current.style.left = 'auto';
} else {
tooltipRef.current.style.left = e.clientX + 15 + 'px';
tooltipRef.current.style.right = 'auto';
}
setTooltipItem(levels[levelIndex][barIndex]);
}
}
},
[rangeMin, rangeMax, totalTicks, clickedItemData, levels]
);
const onGraphMouseLeave = useCallback(() => {
setTooltipItem(undefined);
}, []);
// hide context menu if outside the flame graph canvas is clicked
useEffect(() => {
const handleOnClick = (e: MouseEvent) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
if ((e.target as HTMLElement).parentElement?.id !== 'flameGraphCanvasContainer_clickOutsideCheck') {
setClickedItemData(undefined);
}
};
window.addEventListener('click', handleOnClick);
return () => window.removeEventListener('click', handleOnClick);
}, [setClickedItemData]);
return (
<div className={styles.graph}>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.canvasContainer}>
{sandwichItem && (
<div>
<div
className={styles.sandwichMarker}
style={{ height: (callersCount * PIXELS_PER_LEVEL) / window.devicePixelRatio }}
>
Callers
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
</div>
<div className={styles.sandwichMarker} style={{ marginTop: PIXELS_PER_LEVEL / window.devicePixelRatio }}>
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
</div>
</div>
)}
<div className={styles.canvasWrapper} id="flameGraphCanvasContainer_clickOutsideCheck" ref={sizeRef}>
<canvas
ref={graphRef}
data-testid="flameGraph"
onClick={onGraphClick}
onMouseMove={onGraphMouseMove}
onMouseLeave={onGraphMouseLeave}
/>
</div>
</div>
<FlameGraphTooltip tooltipRef={tooltipRef} item={tooltipItem} data={data} totalTicks={totalTicks} />
{clickedItemData && (
<FlameGraphContextMenu
itemData={clickedItemData}
onMenuItemClick={() => {
setClickedItemData(undefined);
}}
onItemFocus={() => {
setRangeMin(clickedItemData.item.start / totalTicks);
setRangeMax((clickedItemData.item.start + clickedItemData.item.value) / totalTicks);
onItemFocused(clickedItemData);
}}
onSandwich={() => {
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
}}
/>
)}
</div>
);
};
const getStyles = () => ({
graph: css`
overflow: scroll;
height: 100%;
flex-grow: 1;
flex-basis: 50%;
`,
canvasContainer: css`
label: canvasContainer;
display: flex;
`,
canvasWrapper: css`
label: canvasWrapper;
cursor: pointer;
flex: 1;
overflow: hidden;
`,
sandwichMarker: css`
writing-mode: vertical-lr;
transform: rotate(180deg);
overflow: hidden;
white-space: nowrap;
`,
sandwichMarkerIcon: css`
vertical-align: baseline;
`,
});
// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to
// the canvas.
const convertPixelCoordinatesToBarCoordinates = (
// position relative to the start of the graph
pos: { x: number; y: number },
levels: LevelItem[][],
pixelsPerTick: number,
totalTicks: number,
rangeMin: number
) => {
const levelIndex = Math.floor(pos.y / (PIXELS_PER_LEVEL / window.devicePixelRatio));
const barIndex = getBarIndex(pos.x, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin);
return { levelIndex, barIndex };
};
/**
* Binary search for a bar in a level, based on the X pixel coordinate. Useful for detecting which bar did user click
* on.
*/
const getBarIndex = (x: number, level: LevelItem[], pixelsPerTick: number, totalTicks: number, rangeMin: number) => {
if (level) {
let start = 0;
let end = level.length - 1;
while (start <= end) {
const midIndex = (start + end) >> 1;
const startOfBar = getBarX(level[midIndex].start, totalTicks, rangeMin, pixelsPerTick);
const startOfNextBar = getBarX(
level[midIndex].start + level[midIndex].value,
totalTicks,
rangeMin,
pixelsPerTick
);
if (startOfBar <= x && startOfNextBar >= x) {
return midIndex;
}
if (startOfBar > x) {
end = midIndex - 1;
} else {
start = midIndex + 1;
}
}
}
return -1;
};
export default FlameGraph;

View File

@ -0,0 +1,59 @@
import React from 'react';
import { MenuItem, ContextMenu } from '@grafana/ui';
import { ClickedItemData } from '../types';
type Props = {
itemData: ClickedItemData;
onMenuItemClick: () => void;
onItemFocus: () => void;
onSandwich: () => void;
};
const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandwich }: Props) => {
function renderItems() {
return (
<>
<MenuItem
label="Focus block"
icon={'eye'}
onClick={() => {
onItemFocus();
onMenuItemClick();
}}
/>
<MenuItem
label="Copy function name"
icon={'copy'}
onClick={() => {
navigator.clipboard.writeText(itemData.label).then(() => {
onMenuItemClick();
});
}}
/>
<MenuItem
label="Sandwich view"
icon={'gf-show-context'}
onClick={() => {
onSandwich();
onMenuItemClick();
}}
/>
</>
);
}
return (
<div data-testid="contextMenu">
<ContextMenu
renderMenuItems={renderItems}
x={itemData.posX + 10}
y={itemData.posY}
focusOnOpen={false}
></ContextMenu>
</div>
);
};
export default FlameGraphContextMenu;

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import FlameGraphMetadata from './FlameGraphMetadata';
import { textToDataContainer } from './testHelpers';
function setup(props: Partial<React.ComponentProps<typeof FlameGraphMetadata>> = {}) {
const container = textToDataContainer(`
[1//////////////]
[2][4//][7///]
[3][5]
[6]
`)!;
const onFocusPillClick = jest.fn();
const onSandwichPillClick = jest.fn();
const renderResult = render(
<FlameGraphMetadata
data={container}
totalTicks={17}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
{...props}
/>
);
return { renderResult, mocks: { onSandwichPillClick, onFocusPillClick } };
}
describe('FlameGraphMetadata', () => {
it('shows only default pill if not focus or sandwich', () => {
setup();
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Remove focus/)).toBeNull();
expect(screen.queryByLabelText(/Remove sandwich/)).toBeNull();
});
it('shows focus pill', async () => {
const { mocks } = setup({
focusedItem: {
label: '4',
item: {
value: 5,
children: [],
itemIndexes: [3],
start: 3,
},
level: 0,
posX: 0,
posY: 0,
},
});
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
expect(screen.getByText(/29.41% of total/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Remove sandwich/)).toBeNull();
await userEvent.click(screen.getByLabelText(/Remove focus/));
expect(mocks.onFocusPillClick).toHaveBeenCalledTimes(1);
});
it('shows sandwich state', async () => {
const { mocks } = setup({
sandwichedLabel: 'some/random/func.go',
});
expect(screen.getByText(/17 | 17 samples (Count)/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Remove focus/)).toBeNull();
expect(screen.getByText(/func.go/)).toBeInTheDocument();
await userEvent.click(screen.getByLabelText(/Remove sandwich/));
expect(mocks.onSandwichPillClick).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,113 @@
import { css } from '@emotion/css';
import React, { ReactNode } from 'react';
import { getValueFormat, GrafanaTheme2 } from '@grafana/data/src';
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
import { ClickedItemData } from '../types';
import { FlameGraphDataContainer } from './dataTransform';
type Props = {
data: FlameGraphDataContainer;
totalTicks: number;
onFocusPillClick: () => void;
onSandwichPillClick: () => void;
focusedItem?: ClickedItemData;
sandwichedLabel?: string;
};
const FlameGraphMetadata = React.memo(
({ data, focusedItem, totalTicks, sandwichedLabel, onFocusPillClick, onSandwichPillClick }: Props) => {
const styles = useStyles2(getStyles);
const parts: ReactNode[] = [];
const ticksVal = getValueFormat('short')(totalTicks);
const displayValue = data.valueDisplayProcessor(totalTicks);
let unitValue = displayValue.text + displayValue.suffix;
const unitTitle = data.getUnitTitle();
if (unitTitle === 'Count') {
if (!displayValue.suffix) {
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
unitValue = displayValue.text;
}
}
parts.push(
<div className={styles.metadataPill} key={'default'}>
{unitValue} | {ticksVal.text}
{ticksVal.suffix} samples ({unitTitle})
</div>
);
if (sandwichedLabel) {
parts.push(
<span key={'sandwich'}>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={'gf-show-context'} />{' '}
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onSandwichPillClick}
tooltip={'Remove sandwich view'}
aria-label={'Remove sandwich view'}
/>
</div>
</span>
);
}
if (focusedItem) {
const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100;
parts.push(
<span key={'focus'}>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={'eye'} /> {percentValue}% of total
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onFocusPillClick}
tooltip={'Remove focus'}
aria-label={'Remove focus'}
/>
</div>
</span>
);
}
return <>{<div className={styles.metadata}>{parts}</div>}</>;
}
);
FlameGraphMetadata.displayName = 'FlameGraphMetadata';
const getStyles = (theme: GrafanaTheme2) => ({
metadataPill: css`
label: metadataPill;
display: inline-block;
background: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius(8)};
padding: ${theme.spacing(0.5, 1)};
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.fontWeightMedium};
line-height: ${theme.typography.bodySmall.lineHeight};
color: ${theme.colors.text.secondary};
`,
pillCloseButton: css`
label: pillCloseButton;
vertical-align: text-bottom;
margin: ${theme.spacing(0, 0.5)};
`,
metadata: css`
margin: 8px 0;
text-align: center;
`,
});
export default FlameGraphMetadata;

View File

@ -0,0 +1,114 @@
import { Field, FieldType, createDataFrame } from '@grafana/data';
import { getTooltipData } from './FlameGraphTooltip';
import { FlameGraphDataContainer } from './dataTransform';
function setupData(unit?: string) {
const flameGraphData = createDataFrame({
fields: [
{ name: 'level', values: [0] },
unit ? makeField('value', unit, [8_624_078_250]) : { name: 'value', values: [8_624_078_250] },
{ name: 'self', values: [978_250] },
{ name: 'label', values: ['total'] },
],
});
return new FlameGraphDataContainer(flameGraphData);
}
describe('FlameGraphTooltip', () => {
it('for bytes', () => {
const tooltipData = getTooltipData(
setupData('bytes'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentSelf: 0.01,
percentValue: 100,
unitTitle: 'RAM',
unitSelf: '955 KiB',
unitValue: '8.03 GiB',
samples: '8,624,078,250',
});
});
it('with default unit', () => {
const tooltipData = getTooltipData(
setupData('none'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentSelf: 0.01,
percentValue: 100,
unitSelf: '978250',
unitTitle: 'Count',
unitValue: '8624078250',
samples: '8,624,078,250',
});
});
it('without unit', () => {
const tooltipData = getTooltipData(
setupData('none'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentSelf: 0.01,
percentValue: 100,
unitTitle: 'Count',
unitSelf: '978250',
unitValue: '8624078250',
samples: '8,624,078,250',
});
});
it('for objects', () => {
const tooltipData = getTooltipData(
setupData('short'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentSelf: 0.01,
percentValue: 100,
unitTitle: 'Count',
unitSelf: '978 K',
unitValue: '8.62 Bil',
samples: '8,624,078,250',
});
});
it('for nanoseconds', () => {
const tooltipData = getTooltipData(
setupData('ns'),
{ start: 0, itemIndexes: [0], value: 8_624_078_250, children: [] },
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentSelf: 0.01,
percentValue: 100,
unitTitle: 'Time',
unitSelf: '978 µs',
unitValue: '8.62 s',
samples: '8,624,078,250',
});
});
});
function makeField(name: string, unit: string, values: number[]): Field {
return {
name,
type: FieldType.number,
config: {
unit,
},
values: values,
};
}

View File

@ -0,0 +1,118 @@
import { css } from '@emotion/css';
import React, { LegacyRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
type Props = {
data: FlameGraphDataContainer;
totalTicks: number;
item?: LevelItem;
tooltipRef?: LegacyRef<HTMLDivElement>;
};
const FlameGraphTooltip = ({ data, tooltipRef, item, totalTicks }: Props) => {
const styles = useStyles2(getStyles);
let content = null;
if (item) {
const tooltipData = getTooltipData(data, item, totalTicks);
content = (
<div className={styles.tooltipContent}>
<p>{data.getLabel(item.itemIndexes[0])}</p>
<p className={styles.lastParagraph}>
{tooltipData.unitTitle}
<br />
Total: <b>{tooltipData.unitValue}</b> ({tooltipData.percentValue}%)
<br />
Self: <b>{tooltipData.unitSelf}</b> ({tooltipData.percentSelf}%)
<br />
Samples: <b>{tooltipData.samples}</b>
</p>
</div>
);
}
// Even if we don't show tooltip we need this div so the ref is consistently attached. Would need some refactor in
// FlameGraph.tsx to make it work without it.
return (
<div ref={tooltipRef} className={styles.tooltip}>
{content}
</div>
);
};
type TooltipData = {
name: string;
percentValue: number;
percentSelf: number;
unitTitle: string;
unitValue: string;
unitSelf: string;
samples: string;
};
export const getTooltipData = (data: FlameGraphDataContainer, item: LevelItem, totalTicks: number): TooltipData => {
const displayValue = data.valueDisplayProcessor(item.value);
const displaySelf = data.getSelfDisplay(item.itemIndexes);
const percentValue = Math.round(10000 * (displayValue.numeric / totalTicks)) / 100;
const percentSelf = Math.round(10000 * (displaySelf.numeric / totalTicks)) / 100;
let unitValue = displayValue.text + displayValue.suffix;
let unitSelf = displaySelf.text + displaySelf.suffix;
const unitTitle = data.getUnitTitle();
if (unitTitle === 'Count') {
if (!displayValue.suffix) {
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
unitValue = displayValue.text;
}
if (!displaySelf.suffix) {
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
unitSelf = displaySelf.text;
}
}
return {
name: data.getLabel(item.itemIndexes[0]),
percentValue,
percentSelf,
unitTitle,
unitValue,
unitSelf,
samples: displayValue.numeric.toLocaleString(),
};
};
const getStyles = (theme: GrafanaTheme2) => ({
tooltip: css`
title: tooltip;
position: fixed;
`,
tooltipContent: css`
title: tooltipContent;
background-color: ${theme.components.tooltip.background};
border-radius: ${theme.shape.radius.default};
border: 1px solid ${theme.components.tooltip.background};
box-shadow: ${theme.shadows.z2};
color: ${theme.components.tooltip.text};
font-size: ${theme.typography.bodySmall.fontSize};
padding: ${theme.spacing(0.5, 1)};
transition: opacity 0.3s;
z-index: ${theme.zIndex.tooltip};
max-width: 400px;
overflow-wrap: break-word;
`,
lastParagraph: css`
title: lastParagraph;
margin-bottom: 0;
`,
name: css`
title: name;
margin-bottom: 10px;
`,
});
export default FlameGraphTooltip;

View File

@ -0,0 +1,71 @@
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform';
describe('nestedSetToLevels', () => {
it('converts nested set data frame to levels', () => {
// [1------]
// [2---][6]
// [3][5][7]
// [4] [8]
// [9]
const frame = createDataFrame({
fields: [
{ name: 'level', values: [0, 1, 2, 3, 2, 1, 2, 3, 4] },
{ name: 'value', values: [10, 5, 3, 1, 1, 4, 3, 2, 1] },
{ name: 'label', values: ['1', '2', '3', '4', '5', '6', '7', '8', '9'] },
{ name: 'self', values: [0, 0, 0, 0, 0, 0, 0, 0, 0] },
],
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1 };
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2 };
const n7: LevelItem = { itemIndexes: [6], start: 5, children: [n8], value: 3 };
const n6: LevelItem = { itemIndexes: [5], start: 5, children: [n7], value: 4 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [], value: 1 };
const n4: LevelItem = { itemIndexes: [3], start: 0, children: [], value: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [n4], value: 3 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3, n5], value: 5 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n6], value: 10 };
n2.parents = [n1];
n6.parents = [n1];
n3.parents = [n2];
n5.parents = [n2];
n4.parents = [n3];
n7.parents = [n6];
n8.parents = [n7];
n9.parents = [n8];
expect(levels[0]).toEqual([n1]);
expect(levels[1]).toEqual([n2, n6]);
expect(levels[2]).toEqual([n3, n5, n7]);
expect(levels[3]).toEqual([n4, n8]);
expect(levels[4]).toEqual([n9]);
});
it('converts nested set data if multiple same level items', () => {
const frame = createDataFrame({
fields: [
{ name: 'level', values: [0, 1, 1, 1] },
{ name: 'value', values: [10, 5, 3, 1] },
{ name: 'label', values: ['1', '2', '3', '4'] },
{ name: 'self', values: [10, 5, 3, 1] },
],
});
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1 };
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [], value: 5 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n3, n4], value: 10 };
n2.parents = [n1];
n3.parents = [n1];
n4.parents = [n1];
expect(levels[0]).toEqual([n1]);
expect(levels[1]).toEqual([n2, n3, n4]);
});
});

View File

@ -0,0 +1,201 @@
import {
createTheme,
DataFrame,
DisplayProcessor,
Field,
getDisplayProcessor,
getEnumDisplayProcessor,
GrafanaTheme2,
} from '@grafana/data';
import { SampleUnit } from '../types';
import { mergeParentSubtrees, mergeSubtrees } from './treeTransforms';
export type LevelItem = {
// Offset from the start of the level.
start: number;
// Value here can be different from a value of items in the data frame as for callers tree in sandwich view we have
// to trim the value to correspond only to the part used by the children in the subtree.
value: number;
// Index into the data frame. It is an array because for sandwich views we may be merging multiple items into single
// node.
itemIndexes: number[];
children: LevelItem[];
parents?: LevelItem[];
};
/**
* 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[]>] {
const levels: LevelItem[][] = [];
let offset = 0;
let parent: LevelItem | undefined = undefined;
const uniqueLabels: Record<string, LevelItem[]> = {};
for (let i = 0; i < container.data.length; i++) {
const currentLevel = container.getLevel(i);
const prevLevel = i > 0 ? container.getLevel(i - 1) : undefined;
levels[currentLevel] = levels[currentLevel] || [];
if (prevLevel && prevLevel >= currentLevel) {
// We are going down a level or staying at the same level, so we are adding a sibling to the last item in a level.
// So we have to compute the correct offset based on the last sibling.
const lastSibling = levels[currentLevel][levels[currentLevel].length - 1];
offset = lastSibling.start + container.getValue(lastSibling.itemIndexes[0]);
// we assume there is always a single root node so lastSibling should always have a parent.
// Also it has to have the same parent because of how the items are ordered.
parent = lastSibling.parents![0];
}
const newItem: LevelItem = {
itemIndexes: [i],
value: container.getValue(i),
start: offset,
parents: parent && [parent],
children: [],
};
if (uniqueLabels[container.getLabel(i)]) {
uniqueLabels[container.getLabel(i)].push(newItem);
} else {
uniqueLabels[container.getLabel(i)] = [newItem];
}
if (parent) {
parent.children.push(newItem);
}
parent = newItem;
levels[currentLevel].push(newItem);
}
return [levels, uniqueLabels];
}
export class FlameGraphDataContainer {
data: DataFrame;
labelField: Field;
levelField: Field;
valueField: Field;
selfField: Field;
labelDisplayProcessor: DisplayProcessor;
valueDisplayProcessor: DisplayProcessor;
uniqueLabels: string[];
private levels: LevelItem[][] | undefined;
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
constructor(data: DataFrame, theme: GrafanaTheme2 = createTheme()) {
this.data = data;
this.labelField = data.fields.find((f) => f.name === 'label')!;
this.levelField = data.fields.find((f) => f.name === 'level')!;
this.valueField = data.fields.find((f) => f.name === 'value')!;
this.selfField = data.fields.find((f) => f.name === 'self')!;
if (!(this.labelField && this.levelField && this.valueField && this.selfField)) {
throw new Error('Malformed dataFrame: value, level and label and self fields are required.');
}
const enumConfig = this.labelField?.config?.type?.enum;
// Label can actually be an enum field so depending on that we have to access it through display processor. This is
// both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow
// users to use this panel with correct query from data sources that do not return profiles natively.
if (enumConfig) {
this.labelDisplayProcessor = getEnumDisplayProcessor(theme, enumConfig);
this.uniqueLabels = enumConfig.text || [];
} else {
this.labelDisplayProcessor = (value) => ({
text: value + '',
numeric: 0,
});
this.uniqueLabels = [...new Set<string>(this.labelField.values)];
}
this.valueDisplayProcessor = getDisplayProcessor({
field: this.valueField,
theme,
});
}
getLabel(index: number) {
return this.labelDisplayProcessor(this.labelField.values[index]).text;
}
getLevel(index: number) {
return this.levelField.values[index];
}
getValue(index: number | number[]) {
let indexArray: number[] = typeof index === 'number' ? [index] : index;
return indexArray.reduce((acc, index) => {
return acc + this.valueField.values[index];
}, 0);
}
getValueDisplay(index: number | number[]) {
return this.valueDisplayProcessor(this.getValue(index));
}
getSelf(index: number | number[]) {
let indexArray: number[] = typeof index === 'number' ? [index] : index;
return indexArray.reduce((acc, index) => {
return acc + this.selfField.values[index];
}, 0);
}
getSelfDisplay(index: number | number[]) {
return this.valueDisplayProcessor(this.getSelf(index));
}
getUniqueLabels() {
return this.uniqueLabels;
}
getUnitTitle() {
switch (this.valueField.config.unit) {
case SampleUnit.Bytes:
return 'RAM';
case SampleUnit.Nanoseconds:
return 'Time';
}
return 'Count';
}
getLevels() {
this.initLevels();
return this.levels!;
}
getSandwichLevels(label: string) {
const nodes = this.getNodesWithLabel(label);
if (!nodes?.length) {
return [];
}
const callers = mergeParentSubtrees(nodes, this);
const callees = mergeSubtrees(nodes, this);
return [callers, callees];
}
getNodesWithLabel(label: string) {
this.initLevels();
return this.uniqueLabelsMap![label];
}
private initLevels() {
if (!this.levels) {
const [levels, uniqueLabelsMap] = nestedSetToLevels(this);
this.levels = levels;
this.uniqueLabelsMap = uniqueLabelsMap;
}
}
}

View File

@ -0,0 +1,87 @@
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getRectDimensionsForLevel } from './rendering';
function makeDataFrame(fields: Record<string, Array<number | string>>) {
return createDataFrame({
fields: Object.keys(fields).map((key) => ({
name: key,
values: fields[key],
})),
});
}
describe('getRectDimensionsForLevel', () => {
it('should render a single item', () => {
const level: LevelItem[] = [{ start: 0, itemIndexes: [0], children: [], value: 100 }];
const container = new FlameGraphDataContainer(makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }));
const result = getRectDimensionsForLevel(container, level, 1, 100, 0, 10);
expect(result).toEqual([
{
width: 999,
height: 22,
itemIndex: 0,
x: 0,
y: 22,
collapsed: false,
ticks: 100,
label: '1',
unitLabel: '100',
},
]);
});
it('should render a multiple items', () => {
const level: LevelItem[] = [
{ start: 0, itemIndexes: [0], children: [], value: 100 },
{ start: 100, itemIndexes: [1], children: [], value: 50 },
{ start: 150, itemIndexes: [2], children: [], value: 50 },
];
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 50, 50], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
);
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 10);
expect(result).toEqual([
{ width: 999, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
{
width: 499,
height: 22,
x: 1000,
y: 44,
collapsed: false,
ticks: 50,
label: '2',
unitLabel: '50',
itemIndex: 1,
},
{
width: 499,
height: 22,
x: 1500,
y: 44,
collapsed: false,
ticks: 50,
label: '3',
unitLabel: '50',
itemIndex: 2,
},
]);
});
it('should render a collapsed items', () => {
const level: LevelItem[] = [
{ start: 0, itemIndexes: [0], children: [], value: 100 },
{ start: 100, itemIndexes: [1], children: [], value: 2 },
{ start: 102, itemIndexes: [2], children: [], value: 1 },
];
const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100, 2, 1], level: [2, 2, 2], label: ['1', '2', '3'], self: [0, 0, 0] })
);
const result = getRectDimensionsForLevel(container, level, 2, 100, 0, 1);
expect(result).toEqual([
{ width: 99, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1', unitLabel: '100', itemIndex: 0 },
{ width: 3, height: 22, x: 100, y: 44, collapsed: true, ticks: 3, label: '2', unitLabel: '2', itemIndex: 1 },
]);
});
});

View File

@ -0,0 +1,256 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { colors } from '@grafana/ui';
import {
BAR_BORDER_WIDTH,
BAR_TEXT_PADDING_LEFT,
COLLAPSE_THRESHOLD,
HIDE_THRESHOLD,
LABEL_THRESHOLD,
PIXELS_PER_LEVEL,
} from '../../constants';
import { ClickedItemData, TextAlign } from '../types';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
const ufuzzy = new uFuzzy();
export function useFlameRender(
canvasRef: RefObject<HTMLCanvasElement>,
data: FlameGraphDataContainer,
levels: LevelItem[][],
wrapperWidth: number,
rangeMin: number,
rangeMax: number,
search: string,
textAlign: TextAlign,
totalTicks: number,
focusedItemData?: ClickedItemData
) {
const foundLabels = useMemo(() => {
if (search) {
const foundLabels = new Set<string>();
let idxs = ufuzzy.filter(data.getUniqueLabels(), search);
if (idxs) {
for (let idx of idxs) {
foundLabels.add(data.getUniqueLabels()[idx]);
}
}
return foundLabels;
}
// In this case undefined means there was no search so no attempt to highlighting anything should be made.
return undefined;
}, [search, data]);
const ctx = useSetupCanvas(canvasRef, wrapperWidth, levels.length);
useEffect(() => {
if (!ctx) {
return;
}
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin);
for (let levelIndex = 0; levelIndex < levels.length; levelIndex++) {
const level = levels[levelIndex];
// Get all the dimensions of the rectangles for the level. We do this by level instead of per rectangle, because
// sometimes we collapse multiple bars into single rect.
const dimensions = getRectDimensionsForLevel(data, level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
for (const rect of dimensions) {
const focusedLevel = focusedItemData ? focusedItemData.level : 0;
// Render each rectangle based on the computed dimensions
renderRect(ctx, rect, totalTicks, rangeMin, rangeMax, levelIndex, focusedLevel, foundLabels, textAlign);
}
}
}, [
ctx,
data,
levels,
wrapperWidth,
rangeMin,
rangeMax,
search,
focusedItemData,
foundLabels,
textAlign,
totalTicks,
]);
}
function useSetupCanvas(canvasRef: RefObject<HTMLCanvasElement>, wrapperWidth: number, numberOfLevels: number) {
const [ctx, setCtx] = useState<CanvasRenderingContext2D>();
useEffect(() => {
if (!(numberOfLevels && canvasRef.current)) {
return;
}
const ctx = canvasRef.current.getContext('2d')!;
const height = PIXELS_PER_LEVEL * numberOfLevels;
canvasRef.current.width = Math.round(wrapperWidth * window.devicePixelRatio);
canvasRef.current.height = Math.round(height);
canvasRef.current.style.width = `${wrapperWidth}px`;
canvasRef.current.style.height = `${height / window.devicePixelRatio}px`;
ctx.textBaseline = 'middle';
ctx.font = 12 * window.devicePixelRatio + 'px monospace';
ctx.strokeStyle = 'white';
setCtx(ctx);
}, [canvasRef, setCtx, wrapperWidth, numberOfLevels]);
return ctx;
}
type RectData = {
width: number;
height: number;
x: number;
y: number;
collapsed: boolean;
ticks: number;
label: string;
unitLabel: string;
itemIndex: number;
};
/**
* Compute the pixel coordinates for each bar in a level. We need full level of bars so that we can collapse small bars
* into bigger rects.
*/
export function getRectDimensionsForLevel(
data: FlameGraphDataContainer,
level: LevelItem[],
levelIndex: number,
totalTicks: number,
rangeMin: number,
pixelsPerTick: number
): RectData[] {
const coordinatesLevel = [];
for (let barIndex = 0; barIndex < level.length; barIndex += 1) {
const item = level[barIndex];
const barX = getBarX(item.start, totalTicks, rangeMin, pixelsPerTick);
let curBarTicks = item.value;
// merge very small blocks into big "collapsed" ones for performance
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
if (collapsed) {
while (
barIndex < level.length - 1 &&
item.start + curBarTicks === level[barIndex + 1].start &&
level[barIndex + 1].value * pixelsPerTick <= COLLAPSE_THRESHOLD
) {
barIndex += 1;
curBarTicks += level[barIndex].value;
}
}
const displayValue = data.valueDisplayProcessor(item.value);
let unit = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
coordinatesLevel.push({
width,
height: PIXELS_PER_LEVEL,
x: barX,
y: levelIndex * PIXELS_PER_LEVEL,
collapsed,
ticks: curBarTicks,
label: data.getLabel(item.itemIndexes[0]),
unitLabel: unit,
itemIndex: item.itemIndexes[0],
});
}
return coordinatesLevel;
}
export function renderRect(
ctx: CanvasRenderingContext2D,
rect: RectData,
totalTicks: number,
rangeMin: number,
rangeMax: number,
levelIndex: number,
topLevelIndex: number,
foundNames: Set<string> | undefined,
textAlign: TextAlign
) {
if (rect.width < HIDE_THRESHOLD) {
return;
}
ctx.beginPath();
ctx.rect(rect.x + (rect.collapsed ? 0 : BAR_BORDER_WIDTH), rect.y, rect.width, rect.height);
// / (rangeMax - rangeMin) here so when you click a bar it will adjust the top (clicked)bar to the most 'intense' color
const intensity = Math.min(1, rect.ticks / totalTicks / (rangeMax - rangeMin));
const h = 50 - 50 * intensity;
const l = 65 + 7 * intensity;
const name = rect.label;
if (!rect.collapsed) {
ctx.stroke();
if (foundNames) {
ctx.fillStyle = foundNames.has(name) ? getBarColor(h, l) : colors[55];
} else {
ctx.fillStyle = levelIndex > topLevelIndex - 1 ? getBarColor(h, l) : getBarColor(h, l + 15);
}
} else {
ctx.fillStyle = foundNames && foundNames.has(name) ? getBarColor(h, l) : colors[55];
}
ctx.fill();
if (!rect.collapsed && rect.width >= LABEL_THRESHOLD) {
renderLabel(ctx, name, rect, textAlign);
}
}
// Renders a text inside the node rectangle. It allows setting alignment of the text left or right which takes effect
// when text is too long to fit in the rectangle.
function renderLabel(ctx: CanvasRenderingContext2D, name: string, rect: RectData, textAlign: TextAlign) {
ctx.save();
ctx.clip(); // so text does not overflow
ctx.fillStyle = '#222';
// We only measure name here instead of full label because of how we deal with the units and aligning later.
const measure = ctx.measureText(name);
const spaceForTextInRect = rect.width - BAR_TEXT_PADDING_LEFT;
let label = `${name} (${rect.unitLabel})`;
let labelX = Math.max(rect.x, 0) + BAR_TEXT_PADDING_LEFT;
// We use the desired alignment only if there is not enough space for the text, otherwise we keep left alignment as
// that will already show full text.
if (measure.width > spaceForTextInRect) {
ctx.textAlign = textAlign;
// If aligned to the right we don't want to take the space with the unit label as the assumption is user wants to
// mainly see the name. This also reflects how pyro/flamegraph works.
if (textAlign === 'right') {
label = name;
labelX = rect.x + rect.width - BAR_TEXT_PADDING_LEFT;
}
}
ctx.fillText(label, labelX, rect.y + PIXELS_PER_LEVEL / 2);
ctx.restore();
}
/**
* Returns the X position of the bar. totalTicks * rangeMin is to adjust for any current zoom. So if we zoom to a
* section of the graph we align and shift the X coordinates accordingly.
* @param offset
* @param totalTicks
* @param rangeMin
* @param pixelsPerTick
*/
export function getBarX(offset: number, totalTicks: number, rangeMin: number, pixelsPerTick: number) {
return (offset - totalTicks * rangeMin) * pixelsPerTick;
}
function getBarColor(h: number, l: number) {
return `hsl(${h}, 100%, ${l}%)`;
}

View File

@ -0,0 +1,572 @@
export const data = {
version: 1,
flamebearer: {
names: [
'total',
'runtime.mcall',
'runtime.park_m',
'runtime.schedule',
'runtime.resetspinning',
'runtime.wakep',
'runtime.startm',
'runtime.newm',
'runtime.allocm',
'github.com/bufbuild/connect-go.(*duplexHTTPCall).makeRequest',
'net/http.(*Client).Do',
'net/http.(*Client).do',
'net/http.(*Client).send',
'net/http.send',
'test/pkg/util.RoundTripperFunc.RoundTrip',
'test/pkg/util.WrapWithInstrumentedHTTPTransport.func1',
'github.com/opentracing-contrib/go-stdlib/nethttp.(*Transport).RoundTrip',
'github.com/opentracing-contrib/go-stdlib/nethttp.(*Tracer).start',
'github.com/uber/jaeger-client-go.(*Tracer).StartSpan',
'github.com/uber/jaeger-client-go.(*Tracer).startSpanWithOptions',
'github.com/uber/jaeger-client-go.(*Tracer).randomID',
'github.com/uber/jaeger-client-go.NewTracer.func2',
'sync.(*Pool).Get',
'sync.(*Pool).pin',
'sync.(*Pool).pinSlow',
'runtime.mstart',
'runtime.mstart0',
'runtime.mstart1',
'golang.org/x/net/http2.(*serverConn).writeFrameAsync',
'golang.org/x/net/http2.(*writeResHeaders).writeFrame',
'golang.org/x/net/http2.splitHeaderBlock',
'golang.org/x/net/http2.(*writeResHeaders).writeHeaderBlock',
'golang.org/x/net/http2.(*Framer).WriteHeaders',
'golang.org/x/net/http2.(*Framer).endWrite',
'golang.org/x/net/http2.(*bufferedWriter).Write',
'golang.org/x/net/http2.glob..func8',
'bufio.NewWriterSize',
'regexp/syntax.(*compiler).compile',
'regexp/syntax.(*compiler).rune',
'regexp/syntax.(*compiler).inst',
'runtime.systemstack',
'runtime.newproc.func1',
'runtime.newproc1',
'runtime.malg',
'google.golang.org/grpc/internal/transport.newHTTP2Client.func3',
'google.golang.org/grpc/internal/transport.(*loopyWriter).run',
'google.golang.org/grpc/internal/transport.(*loopyWriter).handle',
'google.golang.org/grpc/internal/transport.(*loopyWriter).headerHandler',
'google.golang.org/grpc/internal/transport.(*loopyWriter).originateStream',
'google.golang.org/grpc/internal/transport.(*loopyWriter).writeHeader',
'golang.org/x/net/http2/hpack.(*Encoder).WriteField',
'golang.org/x/net/http2/hpack.(*dynamicTable).add',
'golang.org/x/net/http2/hpack.(*headerFieldTable).addEntry',
'net/http.(*persistConn).readLoop',
'net/http.(*persistConn).readResponse',
'net/http.ReadResponse',
'net/http.serverHandler.ServeHTTP',
'net/http.HandlerFunc.ServeHTTP',
'test/pkg/util.glob..func1.1',
'golang.org/x/net/http2/h2c.h2cHandler.ServeHTTP',
'test/pkg/create.(*create).initServer.func2.1',
'github.com/opentracing-contrib/go-stdlib/nethttp.MiddlewareFunc.func5',
'github.com/weaveworks/common/middleware.Log.Wrap.func1',
'github.com/weaveworks/common/middleware.Instrument.Wrap.func1',
'github.com/felixge/httpsnoop.CaptureMetricsFn',
'github.com/felixge/httpsnoop.(*Metrics).CaptureMetrics',
'github.com/weaveworks/common/middleware.Instrument.Wrap.func1.2',
'github.com/gorilla/mux.(*Router).ServeHTTP',
'net/http.(*ServeMux).ServeHTTP',
'net/http/pprof.Index',
'net/http/pprof.handler.ServeHTTP',
'runtime/pprof.(*Profile).WriteTo',
'runtime/pprof.writeGoroutine',
'runtime/pprof.writeRuntimeProfile',
'runtime/pprof.printCountProfile',
'runtime/pprof.(*profileBuilder).appendLocsForStack',
'runtime/pprof.(*profileBuilder).emitLocation',
'runtime/pprof.(*profileBuilder).flush',
'compress/gzip.(*Writer).Write',
'compress/flate.NewWriter',
'compress/flate.(*compressor).init',
'compress/flate.newHuffmanBitWriter',
'compress/flate.newHuffmanEncoder',
'test/pkg/create.(*create).Run.func3',
'github.com/weaveworks/common/signals.(*Handler).Loop',
'runtime.gcBgMarkWorker',
'runtime.gcMarkDone',
'runtime.semacquire',
'runtime.semacquire1',
'runtime.acquireSudog',
'test/dskit/services.(*BasicService).main',
'test/dskit/ring.(*Ring).loop',
'test/dskit/kv.metrics.WatchKey',
'github.com/weaveworks/common/instrument.CollectedRequest',
'test/dskit/kv.metrics.WatchKey.func1',
'test/dskit/kv.(*prefixedKVClient).WatchKey',
'test/dskit/kv/memberlist.(*Client).WatchKey',
'test/dskit/kv/memberlist.(*KV).WatchKey',
'test/dskit/kv/memberlist.(*KV).get',
'test/dskit/kv/memberlist.ValueDesc.Clone',
'test/dskit/ring.(*Desc).Clone',
'github.com/gogo/protobuf/proto.Clone',
'github.com/gogo/protobuf/proto.Merge',
'test/dskit/ring.(*Desc).XXX_Merge',
'github.com/gogo/protobuf/proto.(*InternalMessageInfo).Merge',
'github.com/gogo/protobuf/proto.(*mergeInfo).merge',
'github.com/gogo/protobuf/proto.(*mergeInfo).computeMergeInfo.func31',
'reflect.Value.SetMapIndex',
'reflect.mapassign_faststr',
'runtime/pprof.profileWriter',
'runtime/pprof.(*profileBuilder).addCPUData',
'runtime/pprof.(*profMap).lookup',
'runtime/pprof.newProfileBuilder',
'compress/gzip.NewWriterLevel',
'runtime/pprof.(*profileBuilder).build',
'compress/flate.newDeflateFast',
'github.com/hashicorp/memberlist.(*Memberlist).triggerFunc',
'github.com/hashicorp/memberlist.(*Memberlist).gossip',
'github.com/armon/go-metrics.MeasureSince',
'github.com/armon/go-metrics.(*Metrics).MeasureSince',
'github.com/armon/go-metrics.(*Metrics).MeasureSinceWithLabels',
'github.com/armon/go-metrics/prometheus.(*PrometheusSink).AddSampleWithLabels',
'github.com/armon/go-metrics/prometheus.flattenKey',
'regexp.(*Regexp).ReplaceAllString',
'regexp.(*Regexp).replaceAll',
'regexp.(*Regexp).doExecute',
'regexp.(*Regexp).backtrack',
'regexp.(*bitState).reset',
'runtime.main',
'main.main',
'test/pkg/create.New',
'github.com/prometheus/common/config.NewClientFromConfig',
'github.com/prometheus/common/config.NewRoundTripperFromConfig',
'github.com/mwitkow/go-conntrack.NewDialContextFunc',
'github.com/mwitkow/go-conntrack.PreRegisterDialerMetrics',
'github.com/prometheus/client_golang/prometheus.(*CounterVec).WithLabelValues',
'github.com/prometheus/client_golang/prometheus.(*CounterVec).GetMetricWithLabelValues',
'github.com/prometheus/client_golang/prometheus.(*MetricVec).GetMetricWithLabelValues',
'github.com/prometheus/client_golang/prometheus.(*metricMap).getOrCreateMetricWithLabelValues',
'test/pkg/create.(*create).Run',
'test/dskit/modules.(*Manager).InitModuleServices',
'test/dskit/modules.(*Manager).initModule',
'test/pkg/create.(*create).initcreate',
'test/pkg/create.New',
'test/pkg/create.(*create).initHead',
'test/pkg/create.NewHead',
'test/pkg/create.(*deduplicatingSlice[...]).Init',
'github.com/segmentio/parquet-go.NewWriter',
'github.com/segmentio/parquet-go.(*Writer).configure',
'github.com/segmentio/parquet-go.newWriter',
'runtime.doInit',
'test/dskit/ring.init',
'html/template.(*Template).Parse',
'text/template.(*Template).Parse',
'text/template/parse.Parse',
'text/template/parse.(*Tree).Parse',
'text/template/parse.(*Tree).parse',
'text/template/parse.(*Tree).textOrAction',
'text/template/parse.(*Tree).action',
'text/template/parse.(*Tree).rangeControl',
'text/template/parse.(*Tree).parseControl',
'text/template/parse.(*Tree).itemList',
'text/template/parse.(*Tree).pipeline',
'text/template/parse.(*PipeNode).append',
'text/template/parse.(*Tree).newPipeline',
'google.golang.org/protobuf/types/known/structpb.init',
'github.com/prometheus/prometheus/scrape.init',
'fmt.Errorf',
'github.com/prometheus/prometheus/discovery/consul.init',
'github.com/prometheus/client_golang/prometheus.(*SummaryVec).WithLabelValues',
'github.com/prometheus/client_golang/prometheus.(*SummaryVec).GetMetricWithLabelValues',
'github.com/prometheus/client_golang/prometheus.NewSummaryVec.func1',
'github.com/prometheus/client_golang/prometheus.newSummary',
'github.com/prometheus/client_golang/prometheus.(*summary).newStream',
'github.com/beorn7/perks/quantile.NewTargeted',
'github.com/beorn7/perks/quantile.newStream',
'encoding/gob.init',
'encoding/gob.mustGetTypeInfo',
'encoding/gob.getTypeInfo',
'encoding/gob.buildTypeInfo',
'encoding/gob.getBaseType',
'encoding/gob.getType',
'encoding/gob.newTypeObject',
'encoding/gob.userType',
'encoding/gob.validUserType',
'sync.(*Map).LoadOrStore',
'sync.(*Map).dirtyLocked',
'go.opentelemetry.io/otel/trace.init',
'regexp.MustCompile',
'regexp.Compile',
'regexp.compile',
'regexp.compileOnePass',
'regexp.onePassCopy',
'cloud.google.com/go/storage.init',
'regexp/syntax.Compile',
'github.com/aws/aws-sdk-go/aws/endpoints.init',
'github.com/asaskevich/govalidator.init',
'regexp/syntax.(*Regexp).CapNames',
'github.com/goccy/go-json/internal/decoder.init.0',
'k8s.io/api/flowcontrol/v1beta2.init',
'k8s.io/kube-openapi/pkg/handler3.init.0',
'mime.AddExtensionType',
'sync.(*Once).Do',
'sync.(*Once).doSlow',
'mime.initMime',
'mime.initMimeUnix',
'mime.loadMimeFile',
'mime.setExtensionType',
'sync.(*Map).Store',
'google.golang.org/genproto/googleapis/rpc/errdetails.init.0',
'google.golang.org/genproto/googleapis/rpc/errdetails.file_google_rpc_error_details_proto_init',
'google.golang.org/protobuf/internal/filetype.Builder.Build',
'google.golang.org/protobuf/internal/filedesc.Builder.Build',
'google.golang.org/protobuf/internal/filedesc.newRawFile',
'google.golang.org/protobuf/internal/filedesc.(*File).unmarshalSeed',
'google.golang.org/protobuf/internal/filedesc.(*Message).unmarshalSeed',
'google.golang.org/protobuf/internal/filedesc.appendFullName',
'google.golang.org/protobuf/internal/strs.(*Builder).AppendFullName',
'google.golang.org/genproto/googleapis/type/color.init.0',
'google.golang.org/genproto/googleapis/type/color.file_google_type_color_proto_init',
'google.golang.org/protobuf/reflect/protoregistry.(*Files).RegisterFile',
'google.golang.org/protobuf/reflect/protoregistry.rangeTopLevelDescriptors',
'google.golang.org/protobuf/reflect/protoregistry.(*Files).RegisterFile.func2',
'github.com/goccy/go-json/internal/encoder.init.0',
'google.golang.org/protobuf/types/descriptorpb.init.0',
'google.golang.org/protobuf/types/descriptorpb.file_google_protobuf_descriptor_proto_init',
'google.golang.org/protobuf/internal/filedesc.(*File).initDecls',
'golang.org/x/net/http2.(*serverConn).runHandler',
'github.com/weaveworks/common/middleware.Tracer.Wrap.func1',
'github.com/weaveworks/common/middleware.getRouteName',
'github.com/gorilla/mux.(*Router).Match',
'github.com/gorilla/mux.(*Route).Match',
'github.com/gorilla/mux.(*routeRegexp).Match',
'regexp.(*Regexp).MatchString',
'regexp.(*Regexp).doMatch',
'test/pkg/agent.(*Target).start.func1',
'test/pkg/agent.(*Target).scrape',
'github.com/prometheus/prometheus/util/pool.(*Pool).Get',
'test/pkg/agent.glob..func1',
'test/pkg/agent.(*Target).fetchProfile',
'io/ioutil.ReadAll',
'io.ReadAll',
'test/pkg/distributor.(*Distributor).Push',
'compress/flate.(*compressor).initDeflate',
'compress/gzip.(*Reader).Read',
'compress/flate.(*decompressor).Read',
'compress/flate.(*decompressor).nextBlock',
'compress/flate.(*decompressor).readHuffman',
'compress/flate.(*huffmanDecoder).init',
'compress/gzip.NewReader',
'compress/gzip.(*Reader).Reset',
'compress/gzip.(*Reader).readHeader',
'compress/flate.NewReader',
'compress/flate.(*dictDecoder).init',
'test/pkg/gen/google/v1.(*Profile).UnmarshalVT',
'test/pkg/gen/google/v1.(*Location).UnmarshalVT',
'test/pkg/gen/google/v1.(*Sample).UnmarshalVT',
'test/pkg/distributor.sanitizeProfile',
'github.com/samber/lo.Reject[...]',
'net/http.(*conn).serve',
'net/http.(*response).finishRequest',
'net/http.putBufioWriter',
'sync.(*Pool).Put',
'net/http.(*conn).readRequest',
'net/http.newBufioWriterSize',
'net/http.readRequest',
'net/textproto.(*Reader).ReadMIMEHeader',
'net/http.newTextprotoReader',
'github.com/uber/jaeger-client-go.NewTracer.func1',
'math/rand.NewSource',
'fmt.Sprintf',
'fmt.newPrinter',
'fmt.glob..func1',
'regexp.newBitState',
'github.com/felixge/httpsnoop.Wrap',
'github.com/bufbuild/connect-go.(*Handler).ServeHTTP',
'net/http.(*Request).WithContext',
'github.com/bufbuild/connect-go.NewUnaryHandler[...].func1',
'github.com/bufbuild/connect-go.(*errorTranslatingSender).Send',
'github.com/bufbuild/connect-go.(*connectUnaryHandlerSender).Send',
'github.com/bufbuild/connect-go.(*connectUnaryMarshaler).Marshal',
'github.com/bufbuild/connect-go.(*bufferPool).Put',
'sync.(*poolChain).pushHead',
'github.com/bufbuild/connect-go.(*compressionPool).Compress',
'github.com/bufbuild/connect-go.(*compressionPool).putCompressor',
'compress/gzip.(*Writer).Close',
'io.Copy',
'io.copyBuffer',
'bytes.(*Buffer).WriteTo',
'github.com/bufbuild/connect-go.(*protoBinaryCodec).Marshal',
'google.golang.org/protobuf/proto.Marshal',
'google.golang.org/protobuf/proto.MarshalOptions.marshal',
'github.com/bufbuild/connect-go.NewUnaryHandler[...].func1.1',
'test/pkg/ingester.(*Ingester).Push',
'github.com/klauspost/compress/gzip.NewReader',
'github.com/klauspost/compress/gzip.(*Reader).Reset',
'github.com/klauspost/compress/gzip.(*Reader).readHeader',
'github.com/klauspost/compress/flate.NewReader',
'github.com/klauspost/compress/flate.(*dictDecoder).init',
'test/pkg/create.(*Head).Ingest',
'test/pkg/create.(*deduplicatingSlice[...]).ingest',
'test/pkg/model.(*LabelsBuilder).Set',
'test/pkg/create.(*Head).convertSamples',
'github.com/bufbuild/connect-go.receiveUnaryRequest[...]',
'github.com/bufbuild/connect-go.(*errorTranslatingReceiver).Receive',
'github.com/bufbuild/connect-go.(*connectUnaryHandlerReceiver).Receive',
'github.com/bufbuild/connect-go.(*connectUnaryUnmarshaler).Unmarshal',
'github.com/bufbuild/connect-go.(*connectUnaryUnmarshaler).UnmarshalFunc',
'bytes.(*Buffer).ReadFrom',
'bytes.(*Buffer).grow',
'bytes.makeSlice',
'github.com/bufbuild/connect-go.(*bufferPool).Get',
'net/http/pprof.Profile',
'runtime/pprof.StartCPUProfile',
'runtime/pprof.writeMutex',
'runtime/pprof.writeProfileInternal',
'runtime/pprof.printCountCycleProfile',
'runtime/pprof.writeBlock',
'runtime/pprof.writeAlloc',
'runtime/pprof.writeHeapInternal',
'runtime/pprof.writeHeapProto',
'runtime/pprof.(*protobuf).strings',
'runtime/pprof.(*protobuf).string',
'runtime/pprof.(*profileBuilder).stringIndex',
'runtime/pprof.(*protobuf).uint64Opt',
'runtime/pprof.(*protobuf).uint64',
'runtime/pprof.(*protobuf).varint',
'runtime/pprof.allFrames',
'runtime/pprof.(*profileBuilder).pbSample',
'runtime/pprof.printCountProfile.func1',
'bytes.(*Buffer).String',
'net/http.(*persistConn).writeLoop',
'net/http.(*Request).write',
'net/http.(*transferWriter).writeBody',
'net/http.(*transferWriter).doBodyCopy',
'test/pkg/distributor.(*Distributor).Push.func1',
'test/pkg/distributor.(*Distributor).sendProfiles',
'test/pkg/distributor.(*Distributor).sendProfilesErr',
'test/pkg/gen/ingester/v1/ingesterv1connect.(*ingesterServiceClient).Push',
'github.com/bufbuild/connect-go.(*Client[...]).CallUnary',
'github.com/bufbuild/connect-go.NewClient[...].func2',
'github.com/bufbuild/connect-go.NewClient[...].func1',
'github.com/bufbuild/connect-go.(*connectClientSender).Send',
'github.com/bufbuild/connect-go.(*errorTranslatingReceiver).Close',
'github.com/bufbuild/connect-go.(*connectUnaryClientReceiver).Close',
'github.com/bufbuild/connect-go.(*duplexHTTPCall).CloseRead',
'github.com/bufbuild/connect-go.discard',
'io.discard.ReadFrom',
'io.glob..func1',
'github.com/bufbuild/connect-go.receiveUnaryResponse[...]',
'github.com/bufbuild/connect-go.(*connectUnaryClientReceiver).Receive',
'github.com/bufbuild/connect-go.(*compressionPool).Decompress',
'github.com/bufbuild/connect-go.(*compressionPool).getDecompressor',
],
levels: [
[0, 8624078250, 0, 0],
[
0, 60011939, 0, 335, 0, 1081684, 0, 331, 0, 2765065247, 0, 259, 0, 144858662, 0, 235, 0, 1081684, 0, 227, 0,
4523250662, 0, 128, 0, 9691644, 0, 116, 0, 8663322, 0, 109, 0, 1574208, 0, 90, 0, 132657008, 0, 85, 0,
304386696, 0, 83, 0, 1049728, 0, 56, 0, 524360, 0, 53, 0, 2624640, 0, 44, 0, 132697488, 0, 40, 0, 545034, 0, 37,
0, 1052676, 0, 28, 0, 398371776, 0, 25, 0, 2099200, 0, 9, 0, 132790592, 0, 1,
],
[
0, 60011939, 0, 336, 0, 1081684, 0, 332, 0, 2756669265, 0, 56, 0, 6821582, 0, 263, 0, 1574400, 0, 260, 0,
144858662, 0, 236, 0, 1081684, 0, 57, 0, 4255866888, 0, 150, 0, 267383774, 0, 129, 0, 9691644, 0, 117, 0,
4444206, 0, 114, 0, 1048752, 0, 112, 0, 3170364, 0, 110, 0, 1574208, 0, 91, 0, 132657008, 0, 86, 0, 304386696,
304386696, 84, 0, 1049728, 0, 57, 0, 524360, 0, 54, 0, 2624640, 0, 45, 0, 132697488, 0, 41, 0, 545034, 0, 37, 0,
1052676, 0, 29, 0, 398371776, 0, 26, 0, 2099200, 0, 10, 0, 132790592, 0, 2,
],
[
0, 60011939, 0, 337, 0, 1081684, 0, 333, 0, 2756669265, 0, 57, 0, 6296270, 0, 265, 0, 525312, 0, 264, 0,
1574400, 0, 261, 0, 135394175, 0, 242, 0, 526980, 0, 239, 0, 8937507, 0, 237, 0, 1081684, 0, 60, 0, 4255866888,
0, 150, 0, 135751342, 0, 139, 0, 131632432, 0, 130, 0, 9691644, 0, 118, 0, 4444206, 0, 78, 0, 1048752, 1048752,
113, 0, 3170364, 3170364, 111, 0, 1574208, 0, 92, 0, 132657008, 0, 87, 304386696, 1049728, 0, 58, 0, 524360,
524360, 55, 0, 2624640, 0, 46, 0, 132697488, 0, 42, 0, 545034, 0, 37, 0, 1052676, 0, 30, 0, 398371776, 0, 27, 0,
2099200, 0, 11, 0, 132790592, 0, 3,
],
[
0, 60011939, 0, 338, 0, 1081684, 0, 334, 0, 2756669265, 0, 58, 0, 1049600, 0, 267, 0, 5246670, 5246670, 266, 0,
525312, 525312, 36, 0, 1574400, 0, 262, 0, 4248808, 0, 257, 0, 35145141, 29377349, 254, 0, 5380182, 0, 249, 0,
5283874, 0, 240, 0, 85336170, 0, 78, 0, 526980, 0, 240, 0, 8937507, 8937507, 238, 0, 1081684, 0, 57, 0,
4255866888, 0, 150, 0, 135751342, 0, 140, 0, 131632432, 0, 131, 0, 9691644, 0, 119, 0, 4444206, 1848496, 79,
4219116, 1574208, 0, 93, 0, 132657008, 0, 88, 304386696, 1049728, 0, 59, 524360, 2624640, 0, 47, 0, 132697488,
132697488, 43, 0, 545034, 0, 37, 0, 1052676, 0, 31, 0, 398371776, 0, 3, 0, 2099200, 0, 12, 0, 132790592, 0, 4,
],
[
0, 60011939, 0, 339, 0, 1081684, 0, 286, 0, 2756669265, 0, 59, 0, 1049600, 0, 22, 5771982, 1574400, 0, 23, 0,
4248808, 4248808, 258, 29377349, 4194832, 4194832, 256, 0, 1572960, 1572960, 255, 0, 5380182, 0, 250, 0,
5283874, 2137058, 241, 0, 85336170, 67470104, 79, 0, 526980, 526980, 241, 8937507, 1081684, 0, 61, 0,
3990564004, 0, 150, 0, 265302884, 0, 151, 0, 135751342, 0, 141, 0, 131632432, 0, 132, 0, 9691644, 0, 120,
1848496, 2595710, 1998711, 80, 4219116, 1574208, 0, 94, 0, 132657008, 132657008, 89, 304386696, 1049728, 0, 57,
524360, 2624640, 0, 48, 132697488, 545034, 0, 37, 0, 1052676, 0, 32, 0, 398371776, 0, 4, 0, 2099200, 0, 13, 0,
132790592, 0, 5,
],
[
0, 60011939, 0, 340, 0, 1081684, 1081684, 287, 0, 2756669265, 0, 57, 0, 1049600, 0, 23, 5771982, 1574400,
1574400, 24, 39393949, 5380182, 0, 251, 2137058, 3146816, 0, 244, 67470104, 17866066, 0, 80, 9464487, 1081684,
0, 228, 0, 3725614404, 0, 150, 0, 132126624, 0, 166, 0, 132822976, 132822976, 165, 0, 265302884, 0, 152, 0,
135751342, 0, 142, 0, 131632432, 0, 133, 0, 9691644, 0, 121, 3847207, 596999, 596999, 115, 4219116, 1574208, 0,
95, 437043704, 1049728, 0, 60, 524360, 2624640, 0, 49, 132697488, 545034, 0, 37, 0, 1052676, 0, 33, 0,
398371776, 0, 5, 0, 2099200, 0, 14, 0, 132790592, 0, 6,
],
[
0, 60011939, 0, 341, 1081684, 2756669265, 0, 60, 0, 1049600, 1049600, 24, 46740331, 5380182, 1053446, 252,
2137058, 3146816, 0, 245, 67470104, 524864, 524864, 81, 0, 17341202, 17341202, 243, 9464487, 1081684, 0, 229, 0,
3725614404, 0, 150, 0, 132126624, 132126624, 167, 132822976, 265302884, 0, 153, 0, 135751342, 0, 143, 0,
131632432, 0, 134, 0, 9691644, 0, 122, 8663322, 1574208, 0, 96, 437043704, 1049728, 0, 57, 524360, 2624640, 0,
50, 132697488, 545034, 0, 37, 0, 1052676, 0, 34, 0, 398371776, 0, 6, 0, 2099200, 0, 15, 0, 132790592, 0, 7,
],
[
0, 35515758, 0, 349, 0, 7925912, 0, 343, 0, 16570269, 0, 278, 1081684, 2756669265, 0, 57, 48843377, 4326736,
4326736, 253, 2137058, 3146816, 0, 246, 94800657, 1081684, 0, 230, 0, 3457203921, 0, 150, 0, 268410483, 0, 168,
264949600, 265302884, 0, 154, 0, 135751342, 0, 144, 0, 131632432, 0, 135, 0, 9691644, 0, 123, 8663322, 1574208,
0, 97, 437043704, 1049728, 0, 61, 524360, 2624640, 0, 51, 132697488, 545034, 0, 37, 0, 1052676, 0, 22, 0,
398371776, 0, 7, 0, 2099200, 0, 16, 0, 132790592, 132790592, 8,
],
[
0, 35515758, 0, 304, 0, 7925912, 0, 344, 0, 16570269, 0, 342, 1081684, 2756669265, 0, 61, 55307171, 3146816, 0,
247, 94800657, 1081684, 0, 231, 0, 3324445713, 0, 150, 0, 132758208, 0, 176, 0, 268410483, 0, 169, 264949600,
265302884, 0, 155, 0, 135751342, 0, 145, 0, 131632432, 0, 136, 0, 9691644, 0, 124, 8663322, 1574208, 0, 98,
437043704, 1049728, 0, 57, 524360, 2624640, 2624640, 52, 132697488, 545034, 0, 37, 0, 1052676, 0, 35, 0,
398371776, 398371776, 8, 0, 2099200, 0, 17,
],
[
0, 35515758, 0, 350, 0, 7925912, 0, 345, 0, 16570269, 0, 280, 1081684, 2740098251, 0, 57, 0, 13936114, 0, 228,
0, 2634900, 0, 18, 55307171, 3146816, 3146816, 248, 94800657, 1081684, 0, 232, 0, 133423345, 0, 224, 0,
2527422102, 0, 150, 0, 264582108, 0, 196, 0, 265453672, 265453672, 195, 0, 132985149, 0, 193, 0, 579337, 0, 187,
0, 132758208, 0, 177, 0, 268410483, 0, 170, 264949600, 265302884, 0, 156, 0, 135751342, 0, 146, 0, 131632432, 0,
137, 0, 9691644, 0, 125, 8663322, 1574208, 0, 99, 437043704, 1049728, 0, 62, 135846488, 545034, 0, 37, 0,
1052676, 1052676, 36, 398371776, 2099200, 0, 18,
],
[
0, 35515758, 0, 306, 0, 7925912, 0, 346, 0, 16045469, 0, 289, 0, 524800, 0, 281, 1081684, 2740098251, 0, 62, 0,
11838546, 0, 229, 0, 2097568, 0, 270, 0, 2634900, 0, 19, 153254644, 1081684, 0, 233, 0, 133423345, 0, 225, 0,
663692876, 663692876, 223, 0, 550717750, 0, 150, 0, 1313011476, 1313011476, 198, 0, 264582108, 0, 188,
265453672, 132985149, 0, 188, 0, 579337, 0, 188, 0, 132758208, 0, 178, 0, 268410483, 0, 137, 264949600,
265302884, 0, 157, 0, 135751342, 0, 147, 0, 131632432, 131632432, 138, 0, 9691644, 0, 126, 8663322, 1574208, 0,
100, 437043704, 1049728, 0, 57, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 19,
],
[
0, 35515758, 0, 307, 0, 7925912, 0, 286, 0, 16045469, 0, 290, 0, 524800, 0, 262, 1081684, 2740098251, 0, 57, 0,
11838546, 0, 230, 0, 2097568, 0, 271, 0, 2634900, 0, 20, 153254644, 1081684, 0, 234, 0, 133423345, 0, 211,
663692876, 143277442, 0, 218, 0, 134728066, 0, 150, 0, 140030946, 0, 200, 0, 132681296, 132681296, 199,
1313011476, 264582108, 0, 189, 265453672, 132985149, 0, 189, 0, 579337, 0, 189, 0, 132758208, 0, 179, 0,
268410483, 0, 138, 264949600, 265302884, 0, 158, 0, 135751342, 0, 148, 131632432, 9691644, 9691644, 127,
8663322, 1574208, 0, 101, 437043704, 1049728, 0, 63, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 20,
],
[
0, 35515758, 0, 351, 0, 7925912, 0, 287, 0, 16045469, 16045469, 291, 0, 524800, 0, 23, 1081684, 2740098251, 0,
63, 0, 11838546, 0, 231, 0, 2097568, 0, 22, 0, 2634900, 0, 21, 153254644, 1081684, 0, 125, 0, 133423345, 0, 212,
663692876, 143277442, 0, 219, 0, 134728066, 0, 209, 0, 140030946, 0, 201, 1445692772, 264582108, 0, 190,
265453672, 132985149, 0, 190, 0, 579337, 0, 190, 0, 132758208, 0, 180, 0, 268410483, 0, 171, 264949600,
265302884, 0, 159, 0, 135751342, 0, 149, 149987398, 1574208, 0, 102, 437043704, 1049728, 0, 64, 135846488,
545034, 0, 37, 399424452, 2099200, 0, 21,
],
[
0, 34466158, 0, 352, 0, 1049600, 0, 308, 0, 7925912, 0, 347, 16045469, 524800, 524800, 24, 1081684, 2740098251,
0, 64, 0, 11838546, 0, 232, 0, 2097568, 2097568, 272, 0, 2634900, 0, 22, 153254644, 1081684, 0, 126, 0,
133423345, 0, 213, 663692876, 143277442, 0, 211, 0, 134728066, 0, 210, 0, 140030946, 0, 202, 1445692772,
132122592, 132122592, 197, 0, 132459516, 0, 194, 265453672, 132985149, 0, 194, 0, 579337, 0, 191, 0, 132758208,
0, 181, 0, 268410483, 0, 172, 264949600, 265302884, 0, 160, 0, 135751342, 135751342, 36, 149987398, 1574208, 0,
103, 437043704, 1049728, 0, 65, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 22,
],
[
0, 34466158, 0, 250, 0, 1049600, 0, 309, 0, 7925912, 0, 22, 17651953, 2740098251, 0, 65, 0, 11838546, 0, 233,
2097568, 2634900, 0, 268, 153254644, 1081684, 1081684, 127, 0, 133423345, 133423345, 226, 663692876, 143277442,
0, 212, 0, 134728066, 0, 211, 0, 140030946, 0, 203, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 37,
0, 579337, 579337, 192, 0, 132758208, 0, 182, 0, 268410483, 0, 173, 264949600, 265302884, 0, 161, 285738740,
1574208, 0, 104, 437043704, 1049728, 0, 66, 135846488, 545034, 0, 37, 399424452, 2099200, 0, 23,
],
[
0, 34466158, 0, 251, 0, 1049600, 1049600, 310, 0, 7925912, 7925912, 348, 17651953, 2739573923, 0, 66, 0, 524328,
524328, 274, 0, 11838546, 0, 234, 2097568, 2634900, 2634900, 269, 951452549, 143277442, 0, 220, 0, 134728066, 0,
212, 0, 140030946, 0, 204, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 37, 579337, 132758208, 0, 183,
0, 268410483, 0, 174, 264949600, 265302884, 0, 157, 285738740, 1574208, 0, 105, 437043704, 1049728, 0, 67,
135846488, 545034, 0, 37, 399424452, 2099200, 2099200, 24,
],
[
0, 34466158, 5260690, 252, 26627465, 2739573923, 0, 67, 524328, 11838546, 0, 125, 956185017, 143277442, 0, 221,
0, 134728066, 0, 213, 0, 140030946, 0, 205, 1577815364, 132459516, 0, 37, 265453672, 132985149, 0, 38, 579337,
132758208, 0, 184, 0, 268410483, 268410483, 175, 264949600, 265302884, 0, 158, 285738740, 1574208, 0, 106,
437043704, 1049728, 0, 68, 135846488, 545034, 0, 37,
],
[
5260690, 29205468, 29205468, 253, 26627465, 409934141, 0, 68, 0, 2329639782, 0, 275, 524328, 11838546, 0, 126,
956185017, 143277442, 143277442, 222, 0, 134728066, 0, 214, 0, 140030946, 0, 206, 1577815364, 132459516, 0, 38,
265453672, 132985149, 132985149, 39, 579337, 132758208, 0, 185, 533360083, 265302884, 0, 162, 285738740,
1574208, 0, 107, 437043704, 1049728, 0, 57, 135846488, 545034, 0, 37,
],
[
61093623, 409934141, 0, 57, 0, 2329115366, 0, 277, 0, 524416, 524416, 276, 524328, 524376, 524376, 273, 0,
11314170, 11314170, 127, 1099462459, 134728066, 0, 215, 0, 140030946, 0, 207, 1577815364, 132459516, 132459516,
39, 399018158, 132758208, 132758208, 186, 533360083, 132657008, 132657008, 164, 0, 132645876, 132645876, 163,
285738740, 1574208, 1574208, 108, 437043704, 1049728, 0, 69, 135846488, 545034, 0, 37,
],
[
61093623, 178075376, 0, 69, 0, 231858765, 0, 312, 0, 14574244, 0, 303, 0, 1624251841, 0, 292, 0, 690289281, 0,
278, 1112349749, 134728066, 0, 216, 0, 140030946, 140030946, 208, 3765070865, 1049728, 0, 70, 135846488, 545034,
0, 37,
],
[
61093623, 178075376, 0, 70, 0, 231858765, 231858765, 313, 0, 14574244, 0, 304, 0, 1624251841, 0, 293, 0,
690289281, 0, 279, 1112349749, 134728066, 134728066, 217, 3905101811, 1049728, 0, 71, 135846488, 545034, 0, 37,
],
[
61093623, 178075376, 0, 71, 231858765, 14574244, 0, 305, 0, 1595244443, 0, 299, 0, 3274238, 3274238, 241, 0,
24651476, 23602660, 254, 0, 1081684, 0, 294, 0, 690289281, 0, 280, 5152179626, 1049728, 0, 72, 135846488,
545034, 0, 37,
],
[
61093623, 37385630, 0, 72, 0, 95081897, 0, 318, 0, 18338066, 0, 317, 0, 27269783, 0, 314, 231858765, 14574244,
0, 306, 0, 1566824927, 1566824927, 302, 0, 1048656, 1048656, 301, 0, 27370860, 27370860, 300, 26876898, 1048816,
1048816, 256, 0, 1081684, 0, 295, 0, 624126, 0, 289, 0, 689140843, 0, 283, 0, 524312, 0, 281, 5152179626,
1049728, 0, 73, 135846488, 545034, 0, 37,
],
[
61093623, 37385630, 0, 73, 0, 95081897, 6643414, 319, 0, 18338066, 0, 315, 0, 27269783, 0, 315, 231858765,
14574244, 0, 307, 1623170157, 1081684, 0, 296, 0, 624126, 0, 290, 0, 12739870, 0, 286, 0, 676400973, 0, 284, 0,
524312, 0, 262, 5152179626, 1049728, 0, 74, 135846488, 545034, 0, 37,
],
[
61093623, 37385630, 0, 74, 6643414, 88438483, 0, 320, 0, 18338066, 0, 316, 0, 27269783, 0, 316, 231858765,
524800, 0, 311, 0, 14049444, 0, 308, 1623170157, 1081684, 0, 297, 0, 624126, 624126, 291, 0, 12739870, 0, 287,
0, 676400973, 0, 285, 0, 524312, 524312, 282, 5152179626, 1049728, 0, 75, 135846488, 545034, 0, 37,
],
[
61093623, 27334886, 0, 75, 0, 9526424, 0, 328, 0, 524320, 0, 329, 6643414, 20792845, 0, 328, 0, 65465889, 0, 75,
0, 2179749, 0, 114, 0, 18338066, 0, 114, 0, 27269783, 0, 114, 231858765, 524800, 0, 22, 0, 14049444, 0, 309,
1623170157, 1081684, 1081684, 298, 624126, 12739870, 0, 288, 0, 676400973, 0, 78, 5152703938, 1049728, 0, 76,
135846488, 545034, 0, 37,
],
[
61093623, 26810494, 1585182, 76, 0, 524392, 524392, 327, 0, 9526424, 0, 77, 0, 524320, 524320, 330, 6643414,
20792845, 0, 77, 0, 8389952, 8389952, 327, 0, 57075937, 2171836, 76, 0, 2179749, 0, 321, 0, 18338066, 0, 78, 0,
27269783, 0, 78, 231858765, 524800, 0, 23, 0, 14049444, 14049444, 310, 1624875967, 12739870, 0, 78, 0,
676400973, 557321544, 79, 5152703938, 1049728, 0, 77, 135846488, 545034, 0, 37,
],
[
62678805, 25225312, 0, 77, 524392, 9526424, 0, 78, 7167734, 20792845, 0, 78, 10561788, 49063106, 0, 77, 0,
1050624, 0, 324, 0, 4790371, 4790371, 323, 0, 2179749, 2179749, 322, 0, 18338066, 9242480, 79, 0, 27269783,
18484960, 79, 231858765, 524800, 524800, 24, 1638925411, 12739870, 11090976, 79, 557321544, 119079429, 0, 80,
5152703938, 1049728, 0, 78, 135846488, 545034, 0, 37,
],
[
62678805, 25225312, 0, 78, 524392, 9526424, 4621240, 79, 7167734, 20792845, 11090976, 79, 10561788, 49063106, 0,
78, 0, 1050624, 0, 325, 16212600, 9095586, 5670636, 80, 18484960, 8784823, 8227085, 80, 1882399952, 1648894, 0,
80, 557321544, 2097312, 0, 81, 0, 116982117, 116982117, 243, 5152703938, 1049728, 0, 79, 135846488, 545034, 0,
37,
],
[
62678805, 25225312, 14787968, 79, 5145632, 4905184, 1998711, 80, 18258710, 9701869, 6119875, 80, 10561788,
49063106, 25878944, 79, 0, 1050624, 1050624, 326, 21883236, 3424950, 3424950, 115, 26712045, 557738, 557738,
115, 1882399952, 524864, 524864, 81, 0, 1124030, 1124030, 243, 557321544, 2097312, 2097312, 82, 5269686055,
1049728, 0, 80, 135846488, 545034, 0, 37,
],
[
77466773, 10437344, 6336873, 80, 7144343, 2906473, 2906473, 115, 24378585, 3581994, 3581994, 115, 36440732,
23184162, 14346960, 80, 7766782350, 1049728, 0, 81, 135846488, 545034, 0, 38,
],
[
83803646, 4100471, 4100471, 115, 88799087, 8837202, 8837202, 115, 7766782350, 1049728, 1049728, 82, 135846488,
545034, 545034, 39,
],
],
numTicks: 8624078250,
},
timeline: null,
};

View File

@ -0,0 +1,50 @@
import { LevelItem } from './dataTransform';
import { levelsToString, textToDataContainer, trimLevelsString } from './testHelpers';
describe('textToDataContainer', () => {
it('converts text to correct data container', () => {
const container = textToDataContainer(`
[1//////////////]
[2][4//][7///]
[3][5]
[6]
`)!;
const n6: LevelItem = { itemIndexes: [5], start: 3, children: [], value: 3 };
const n5: LevelItem = { itemIndexes: [4], start: 3, children: [n6], value: 3 };
const n3: LevelItem = { itemIndexes: [2], start: 0, children: [], value: 3 };
const n7: LevelItem = { itemIndexes: [6], start: 8, children: [], value: 6 };
const n4: LevelItem = { itemIndexes: [3], start: 3, children: [n5], value: 5 };
const n2: LevelItem = { itemIndexes: [1], start: 0, children: [n3], value: 3 };
const n1: LevelItem = { itemIndexes: [0], start: 0, children: [n2, n4, n7], value: 17 };
n2.parents = [n1];
n4.parents = [n1];
n7.parents = [n1];
n3.parents = [n2];
n5.parents = [n4];
n6.parents = [n5];
const levels = container.getLevels();
expect(levels[0][0]).toEqual(n1);
});
});
describe('levelsToString', () => {
it('converts data container to correct string', () => {
const stringGraph = trimLevelsString(`
[1//////////////]
[2][4//][7///]
[3][5]
[6]
`);
const container = textToDataContainer(stringGraph)!;
expect(levelsToString(container.getLevels(), container)).toEqual(stringGraph);
});
});

View File

@ -0,0 +1,102 @@
import { arrayToDataFrame } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
// Convert text to a FlameGraphDataContainer for testing. The format representing the flamegraph for example:
// [0///////]
// [1//][4//]
// [2//][5]
// [3] [6]
// [7]
// Each node starts with [ ends with ], single digit is used for label and the length of a node is it's value.
export function textToDataContainer(text: string) {
const levels = text.split('\n');
if (levels.length === 0) {
return undefined;
}
if (levels[0] === '') {
levels.shift();
}
const dfValues: Array<{ level: number; value: number; label: string; self: number }> = [];
const dfSorted: Array<{ level: number; value: number; label: string; self: number }> = [];
const leftMargin = levels[0].indexOf('[');
let itemLevels: LevelItem[][] = [];
const re = /\[(\d)[^\[]*]/g;
let match;
for (let i = 0; i < levels.length; i++) {
while ((match = re.exec(levels[i])) !== null) {
const currentNodeValue = match[0].length;
dfValues.push({
value: match[0].length,
label: match[1],
self: match[0].length,
level: i,
});
const node: LevelItem = {
value: match[0].length,
itemIndexes: [dfValues.length - 1],
start: match.index - leftMargin,
children: [],
};
itemLevels[i] = itemLevels[i] || [];
itemLevels[i].push(node);
const prevLevel = itemLevels[i - 1];
if (prevLevel) {
for (const n of prevLevel) {
const nRow = dfValues[n.itemIndexes[0]];
const value = nRow.value;
if (n.start + value > node.start) {
n.children.push(node);
nRow.self = nRow.self - currentNodeValue;
break;
}
}
}
}
}
const root = itemLevels[0][0];
const stack = [root];
while (stack.length) {
const node = stack.shift()!;
const index = node.itemIndexes[0];
dfSorted.push(dfValues[index]);
node.itemIndexes = [dfSorted.length - 1];
if (node.children) {
stack.unshift(...node.children);
}
}
const df = arrayToDataFrame(dfSorted);
return new FlameGraphDataContainer(df);
}
export function trimLevelsString(s: string) {
const lines = s.split('\n').filter((l) => !l.match(/^\s*$/));
const offset = Math.min(lines[0].indexOf('['), lines[lines.length - 1].indexOf('['));
return lines.map((l) => l.substring(offset)).join('\n');
}
// Convert levels array to a string representation that can be visually compared. Mainly useful together with
// textToDataContainer to create more visual tests.
export function levelsToString(levels: LevelItem[][], data: FlameGraphDataContainer) {
let sLevels = [];
for (const level of levels) {
let sLevel = ' '.repeat(level[0].start);
for (const node of level) {
sLevel += ' '.repeat(node.start - sLevel.length);
sLevel += `[${data.getLabel(node.itemIndexes[0])}${'/'.repeat(node.value - 3)}]`;
}
sLevels.push(sLevel);
}
return sLevels.join('\n');
}

View File

@ -0,0 +1,162 @@
import { levelsToString, textToDataContainer, trimLevelsString } from './testHelpers';
import { mergeParentSubtrees, mergeSubtrees } from './treeTransforms';
describe('mergeSubtrees', () => {
it('correctly merges trees', () => {
const container = textToDataContainer(`
[0////////////]
[1//][4/////]
[2] [1////]
[3] [2][7/]
[8]
`)!;
const levels = container.getLevels()!;
const node1 = levels[1][0];
const node2 = levels[2][1];
const merged = mergeSubtrees([node1, node2], container);
expect(merged[0][0]).toMatchObject({ itemIndexes: [1, 5], start: 0 });
expect(merged[1][0]).toMatchObject({ itemIndexes: [2, 6], start: 0 });
expect(merged[1][1]).toMatchObject({ itemIndexes: [7], start: 6 });
expect(merged[2][0]).toMatchObject({ itemIndexes: [3], start: 0 });
expect(merged[2][1]).toMatchObject({ itemIndexes: [8], start: 6 });
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[1/////////]
[2///][7/]
[3] [8]
`)
);
});
it('normalizes the tree offset for single node', () => {
const container = textToDataContainer(`
[0////////////]
[1//][4/////]
[2] [5////]
[3] [6][7/]
[8]
`)!;
const levels = container.getLevels()!;
const node = levels[1][1];
const merged = mergeSubtrees([node], container);
expect(merged[0][0]).toMatchObject({ itemIndexes: [4], start: 0 });
expect(merged[1][0]).toMatchObject({ itemIndexes: [5], start: 0 });
expect(merged[2][0]).toMatchObject({ itemIndexes: [6], start: 0 });
expect(merged[2][1]).toMatchObject({ itemIndexes: [7], start: 3 });
expect(merged[3][0]).toMatchObject({ itemIndexes: [8], start: 3 });
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[4/////]
[5////]
[6][7/]
[8]
`)
);
});
it('handles repeating items', () => {
const container = textToDataContainer(`
[0]
[0]
[0]
[0]
`)!;
const levels = container.getLevels()!;
const merged = mergeSubtrees([levels[0][0]], container);
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[0]
[0]
[0]
[0]
`)
);
});
});
describe('mergeParentSubtrees', () => {
it('correctly merges trees', () => {
const container = textToDataContainer(`
[0/////////////]
[1//][4/////][6]
[2] [5/////]
[6] [6/][8/]
[7]
`)!;
const levels = container.getLevels()!;
const merged = mergeParentSubtrees([levels[3][0], levels[3][1], levels[1][2]], container);
expect(merged[0][0]).toMatchObject({ itemIndexes: [0], start: 3, value: 3 });
expect(merged[0][1]).toMatchObject({ itemIndexes: [0], start: 6, value: 4 });
expect(merged[1][0]).toMatchObject({ itemIndexes: [1], start: 3, value: 3 });
expect(merged[1][1]).toMatchObject({ itemIndexes: [4], start: 6, value: 4 });
expect(merged[2][0]).toMatchObject({ itemIndexes: [0], start: 0, value: 3 });
expect(merged[2][1]).toMatchObject({ itemIndexes: [2], start: 3, value: 3 });
expect(merged[2][2]).toMatchObject({ itemIndexes: [5], start: 6, value: 4 });
expect(merged[3][0]).toMatchObject({ itemIndexes: [3, 6, 9], start: 0, value: 10 });
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[0][0/]
[1][4/]
[0][2][5/]
[6///////]
`)
);
});
it('handles repeating nodes in single parent tree', () => {
const container = textToDataContainer(`
[0]
[1]
[2]
[1]
[4]
`)!;
const levels = container.getLevels()!;
const merged = mergeParentSubtrees([levels[1][0], levels[3][0]], container);
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[0]
[1]
[0][2]
[1///]
`)
);
});
it('handles single node', () => {
const container = textToDataContainer(`[0]`)!;
const levels = container.getLevels()!;
const merged = mergeParentSubtrees([levels[0][0]], container);
expect(levelsToString(merged, container)).toEqual(trimLevelsString(`[0]`));
});
it('handles multiple same nodes', () => {
const container = textToDataContainer(`
[0]
[0]
[0]
[0]
[0]
`)!;
const levels = container.getLevels()!;
const merged = mergeParentSubtrees([levels[4][0]], container);
expect(levelsToString(merged, container)).toEqual(
trimLevelsString(`
[0]
[0]
[0]
[0]
[0]
`)
);
});
});

View File

@ -0,0 +1,125 @@
import { groupBy } from 'lodash';
import { LevelItem } from './dataTransform';
type DataInterface = {
getLabel: (index: number) => string;
};
// Merge parent subtree of the roots for the callers tree in the sandwich view of the flame graph.
export function mergeParentSubtrees(roots: LevelItem[], data: DataInterface): LevelItem[][] {
const newRoots = getParentSubtrees(roots);
return mergeSubtrees(newRoots, data, 'parents');
}
// Returns a subtrees per root that will have the parents resized to the same value as the root. When doing callers
// tree we need to keep proper sizes of the parents, before we merge them, so we correctly attribute to the parents
// only the value it contributed to the root.
// So if we have something like:
// [0/////////////]
// [1//][4/////][6]
// [2] [5/////]
// [6] [6/][8/]
// [7]
// Taking all the node with '6' will create:
// [0][0/]
// [1][4/]
// [2][5/][0]
// [6][6/][6]
// Which we can later merge.
function getParentSubtrees(roots: LevelItem[]) {
return roots.map((r) => {
if (!r.parents?.length) {
return r;
}
const newRoot = {
...r,
children: [],
};
const stack: Array<{ child: undefined | LevelItem; parent: LevelItem }> = [
{ child: newRoot, parent: r.parents[0] },
];
while (stack.length) {
const args = stack.shift()!;
const newNode = {
...args.parent,
children: args.child ? [args.child] : [],
parents: [],
};
if (args.child) {
newNode.value = args.child.value;
args.child.parents = [newNode];
}
if (args.parent.parents?.length) {
stack.push({ child: newNode, parent: args.parent.parents[0] });
}
}
return newRoot;
});
}
// Merge subtrees into a single tree. Returns an array of levels for easy rendering. It assumes roots are mergeable,
// meaning they represent the same unit of work (same label). Then we walk the tree in a specified direction,
// merging nodes with the same label and same parent/child into single bigger node. This copies the tree (and all nodes)
// as we are creating new merged nodes and modifying the parents/children.
export function mergeSubtrees(
roots: LevelItem[],
data: DataInterface,
direction: 'parents' | 'children' = 'children'
): LevelItem[][] {
const oppositeDirection = direction === 'parents' ? 'children' : 'parents';
const levels: LevelItem[][] = [];
// Loop instead of recursion to be sure we don't blow stack size limit and save some memory. Each stack item is
// basically a list of arrays you would pass to each level of recursion.
const stack: Array<{ previous: undefined | LevelItem; items: LevelItem[]; level: number }> = [
{ previous: undefined, items: roots, level: 0 },
];
while (stack.length) {
const args = stack.shift()!;
const indexes = args.items.flatMap((i) => i.itemIndexes);
const newItem: LevelItem = {
// We use the items value instead of value from the data frame, cause we could have changed it in the process
value: args.items.reduce((acc, i) => acc + i.value, 0),
itemIndexes: indexes,
// these will change later
children: [],
parents: [],
start: 0,
};
levels[args.level] = levels[args.level] || [];
levels[args.level].push(newItem);
if (args.previous) {
// Not the first level, so we need to make sure we update previous items to keep the child/parent relationships
// and compute correct new start offset for the item.
newItem[oppositeDirection] = [args.previous];
const prevSiblingsVal =
args.previous[direction]?.reduce((acc, node) => {
return acc + node.value;
}, 0) || 0;
newItem.start = args.previous.start + prevSiblingsVal;
args.previous[direction]!.push(newItem);
}
const nextItems = args.items.flatMap((i) => i[direction] || []);
// Group by label which for now is the only identifier by which we decide if node represents the same unit of work.
const nextGroups = groupBy(nextItems, (c) => data.getLabel(c.itemIndexes[0]));
for (const g of Object.values(nextGroups)) {
stack.push({ previous: newItem, items: g, level: args.level + 1 });
}
}
// Reverse the levels if we are doing callers tree, so we return levels in the correct order.
if (direction === 'parents') {
levels.reverse();
}
return levels;
}

View File

@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { CoreApp, createDataFrame } from '@grafana/data';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
import { data } from './FlameGraph/testData/dataNestedSet';
import FlameGraphContainer from './FlameGraphContainer';
jest.mock('react-use', () => ({
useMeasure: () => {
const ref = React.useRef();
return [ref, { width: 1600 }];
},
}));
describe('FlameGraphContainer', () => {
// Needed for AutoSizer to work in test
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { value: 500 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { value: 500 });
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { value: 500 });
const FlameGraphContainerWithProps = () => {
const flameGraphData = createDataFrame(data);
flameGraphData.meta = {
custom: {
ProfileTypeID: 'cpu:foo:bar',
},
};
return <FlameGraphContainer data={flameGraphData} app={CoreApp.Explore} />;
};
it('should render without error', async () => {
expect(() => render(<FlameGraphContainerWithProps />)).not.toThrow();
});
it('should update search when row selected in top table', async () => {
render(<FlameGraphContainerWithProps />);
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]);
expect(screen.getByDisplayValue('net/http.HandlerFunc.ServeHTTP')).toBeInTheDocument();
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
expect(screen.getByDisplayValue('total')).toBeInTheDocument();
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
expect(screen.queryByDisplayValue('total')).not.toBeInTheDocument();
});
it('should render options', async () => {
render(<FlameGraphContainerWithProps />);
expect(screen.getByText(/Top Table/)).toBeDefined();
expect(screen.getByText(/Flame Graph/)).toBeDefined();
expect(screen.getByText(/Both/)).toBeDefined();
});
it('should update selected view', async () => {
render(<FlameGraphContainerWithProps />);
expect(screen.getByTestId('flameGraph')).toBeDefined();
expect(screen.getByTestId('topTable')).toBeDefined();
await userEvent.click(screen.getByText(/Top Table/));
expect(screen.queryByTestId('flameGraph')).toBeNull();
expect(screen.getByTestId('topTable')).toBeDefined();
await userEvent.click(screen.getByText(/Flame Graph/));
expect(screen.getByTestId('flameGraph')).toBeDefined();
expect(screen.queryByTestId('topTable')).toBeNull();
await userEvent.click(screen.getByText(/Both/));
expect(screen.getByTestId('flameGraph')).toBeDefined();
expect(screen.getByTestId('topTable')).toBeDefined();
});
it('should render both option if screen width >= threshold', async () => {
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH;
global.dispatchEvent(new Event('resize')); // Trigger the window resize event
render(<FlameGraphContainerWithProps />);
expect(screen.getByText(/Both/)).toBeDefined();
});
it('should not render both option if screen width < threshold', async () => {
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH - 1;
global.dispatchEvent(new Event('resize'));
render(<FlameGraphContainerWithProps />);
expect(screen.queryByTestId(/Both/)).toBeNull();
});
});

View File

@ -0,0 +1,157 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, CoreApp, GrafanaTheme2 } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
import FlameGraph from './FlameGraph/FlameGraph';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { ClickedItemData, SelectedView, TextAlign } from './types';
type Props = {
data?: DataFrame;
app: CoreApp;
};
const FlameGraphContainer = (props: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Both);
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [textAlign, setTextAlign] = useState<TextAlign>('left');
// This is a label of the item because in sandwich view we group all items by label and present a merged graph
const [sandwichItem, setSandwichItem] = useState<string>();
const theme = useTheme2();
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
if (!props.data) {
return;
}
return new FlameGraphDataContainer(props.data, theme);
}, [props.data, theme]);
const styles = useStyles2(getStyles);
// If user resizes window with both as the selected view
useEffect(() => {
if (
containerWidth > 0 &&
containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH &&
selectedView === SelectedView.Both
) {
setSelectedView(SelectedView.FlameGraph);
}
}, [selectedView, setSelectedView, containerWidth]);
function resetFocus() {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}
function resetSandwich() {
setSandwichItem(undefined);
}
useEffect(() => {
resetFocus();
resetSandwich();
}, [props.data]);
return (
<>
{dataContainer && (
<div ref={sizeRef} className={styles.container}>
<FlameGraphHeader
app={props.app}
search={search}
setSearch={setSearch}
selectedView={selectedView}
setSelectedView={setSelectedView}
containerWidth={containerWidth}
onReset={() => {
resetFocus();
resetSandwich();
}}
textAlign={textAlign}
onTextAlignChange={setTextAlign}
showResetButton={Boolean(focusedItemData || sandwichItem)}
/>
<div className={styles.body}>
{selectedView !== SelectedView.FlameGraph && (
<FlameGraphTopTableContainer
data={dataContainer}
app={props.app}
onSymbolClick={(symbol) => {
if (search === symbol) {
setSearch('');
} else {
reportInteraction('grafana_flamegraph_table_item_selected', {
app: props.app,
grafana_version: config.buildInfo.version,
});
setSearch(symbol);
resetFocus();
}
}}
height={selectedView === SelectedView.TopTable ? 600 : undefined}
/>
)}
{selectedView !== SelectedView.TopTable && (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
search={search}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
/>
)}
</div>
</div>
)}
</>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
height: '100%',
display: 'flex',
flex: '1 1 0',
flexDirection: 'column',
minHeight: 0,
gap: theme.spacing(1),
}),
body: css({
display: 'flex',
flexGrow: 1,
minHeight: 0,
}),
};
}
export default FlameGraphContainer;

View File

@ -0,0 +1,58 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { CoreApp } from '@grafana/data';
import FlameGraphHeader from './FlameGraphHeader';
import { SelectedView } from './types';
describe('FlameGraphHeader', () => {
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
const setSearch = jest.fn();
const setSelectedView = jest.fn();
const onReset = jest.fn();
const renderResult = render(
<FlameGraphHeader
app={CoreApp.Explore}
search={''}
setSearch={setSearch}
selectedView={SelectedView.Both}
setSelectedView={setSelectedView}
containerWidth={1600}
onReset={onReset}
onTextAlignChange={jest.fn()}
textAlign={'left'}
showResetButton={true}
{...props}
/>
);
return {
renderResult,
handlers: {
setSearch,
setSelectedView,
onReset,
},
};
}
it('show reset button when needed', async () => {
setup({ showResetButton: false });
expect(screen.queryByLabelText(/Reset focus/)).toBeNull();
setup();
expect(screen.getByLabelText(/Reset focus/)).toBeInTheDocument();
});
it('calls on reset when reset button is clicked', async () => {
const { handlers } = setup();
const resetButton = screen.getByLabelText(/Reset focus/);
expect(resetButton).toBeInTheDocument();
await userEvent.click(resetButton);
expect(handlers.onReset).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,214 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import usePrevious from 'react-use/lib/usePrevious';
import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { config } from '../../../../../core/config';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
import { SelectedView, TextAlign } from './types';
type Props = {
app: CoreApp;
search: string;
setSearch: (search: string) => void;
selectedView: SelectedView;
setSelectedView: (view: SelectedView) => void;
containerWidth: number;
onReset: () => void;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
showResetButton: boolean;
};
const FlameGraphHeader = ({
app,
search,
setSearch,
selectedView,
setSelectedView,
containerWidth,
onReset,
textAlign,
onTextAlignChange,
showResetButton,
}: Props) => {
const styles = useStyles2((theme) => getStyles(theme, app));
function interaction(name: string, context: Record<string, string | number>) {
reportInteraction(`grafana_flamegraph_${name}`, {
app,
grafana_version: config.buildInfo.version,
...context,
});
}
const [localSearch, setLocalSearch] = useSearchInput(search, setSearch);
const suffix =
localSearch !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={() => {
// We could set only one and wait them to sync but there is no need to debounce this.
setSearch('');
setLocalSearch('');
}}
>
Clear
</Button>
) : null;
return (
<div className={styles.header}>
<div className={styles.inputContainer}>
<Input
value={localSearch || ''}
onChange={(v) => {
setLocalSearch(v.currentTarget.value);
}}
placeholder={'Search..'}
width={44}
suffix={suffix}
/>
</div>
<div className={styles.rightContainer}>
{showResetButton && (
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
icon={'history-alt'}
tooltip={'Reset focus and sandwich state'}
onClick={() => {
onReset();
}}
className={styles.buttonSpacing}
aria-label={'Reset focus and sandwich state'}
/>
)}
<RadioButtonGroup<TextAlign>
size="sm"
disabled={selectedView === SelectedView.TopTable}
options={alignOptions}
value={textAlign}
onChange={(val) => {
interaction('text_align_selected', { align: val });
onTextAlignChange(val);
}}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth)}
value={selectedView}
onChange={(view) => {
interaction('view_selected', { view });
setSelectedView(view);
}}
/>
</div>
</div>
);
};
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
function getViewOptions(width: number): Array<SelectableValue<SelectedView>> {
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
];
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH) {
viewOptions.push({
value: SelectedView.Both,
label: 'Both',
description: 'Show both the top table and flame graph',
});
}
return viewOptions;
}
function useSearchInput(
search: string,
setSearch: (search: string) => void
): [string | undefined, (search: string) => void] {
const [localSearchState, setLocalSearchState] = useState(search);
const prevSearch = usePrevious(search);
// Debouncing cause changing parent search triggers rerender on both the flamegraph and table
useDebounce(
() => {
setSearch(localSearchState);
},
250,
[localSearchState]
);
// Make sure we still handle updates from parent (from clicking on a table item for example). We check if the parent
// search value changed to something that isn't our local value.
useEffect(() => {
if (prevSearch !== search && search !== localSearchState) {
setLocalSearchState(search);
}
}, [search, prevSearch, localSearchState]);
return [localSearchState, setLocalSearchState];
}
const getStyles = (theme: GrafanaTheme2, app: CoreApp) => ({
header: css`
label: header;
display: flex;
justify-content: space-between;
width: 100%;
background: ${theme.colors.background.primary};
top: 0;
z-index: ${theme.zIndex.navbarFixed};
${app === CoreApp.Explore
? css`
position: sticky;
padding-bottom: ${theme.spacing(1)};
padding-top: ${theme.spacing(1)};
`
: ''};
`,
inputContainer: css`
label: inputContainer;
margin-right: 20px;
`,
rightContainer: css`
label: rightContainer;
display: flex;
align-items: flex-start;
`,
buttonSpacing: css`
label: buttonSpacing;
margin-right: ${theme.spacing(1)};
`,
resetButton: css`
label: resetButton;
display: flex;
margin-right: ${theme.spacing(2)};
`,
resetButtonIconWrapper: css`
label: resetButtonIcon;
padding: 0 5px;
color: ${theme.colors.text.disabled};
`,
});
export default FlameGraphHeader;

View File

@ -0,0 +1,126 @@
import { FlamegraphRenderer } from '@pyroscope/flamegraph';
import React from 'react';
import '@pyroscope/flamegraph/dist/index.css';
import { CoreApp, DataFrame, DataFrameView } from '@grafana/data';
import { config } from '@grafana/runtime';
import FlameGraphContainer from './FlameGraphContainer';
type Props = {
data?: DataFrame;
app: CoreApp;
// Height for flame graph when not used in explore.
// This needs to be different to explore flame graph height as we
// use panels with user adjustable heights in dashboards etc.
flameGraphHeight?: number;
};
export const FlameGraphTopWrapper = (props: Props) => {
if (config.featureToggles.pyroscopeFlameGraph) {
const profile = props.data ? dataFrameToFlameBearer(props.data) : undefined;
return <FlamegraphRenderer profile={profile} />;
}
return <FlameGraphContainer data={props.data} app={props.app} />;
};
type Row = {
level: number;
label: string;
value: number;
self: number;
};
/**
* Converts a nested set format from a DataFrame to a Flamebearer format needed by the pyroscope flamegraph.
* @param data
*/
function dataFrameToFlameBearer(data: DataFrame) {
// Unfortunately we cannot use @pyroscope/models for now as they publish ts files which then get type checked and
// they do not pass our with our tsconfig
const profile: any = {
version: 1,
flamebearer: {
names: [],
levels: [],
numTicks: 0,
maxSelf: 0,
},
metadata: {
format: 'single' as const,
sampleRate: 100,
spyName: 'gospy' as const,
units: 'samples' as const,
},
};
const view = new DataFrameView<Row>(data);
const labelField = data.fields.find((f) => f.name === 'label');
if (labelField?.config?.type?.enum?.text) {
profile.flamebearer.names = labelField.config.type.enum.text;
}
const labelMap: Record<string, number> = {};
// Handle both cases where label is a string or a number pointing to enum config text array.
const getLabel = (label: string | number) => {
if (typeof label === 'number') {
return label;
} else {
if (labelMap[label] === undefined) {
labelMap[label] = profile.flamebearer.names.length;
profile.flamebearer.names.push(label);
}
return labelMap[label];
}
};
// Absolute offset where we are currently at.
let offset = 0;
for (let i = 0; i < data.length; i++) {
// view.get() changes the underlying object, so we have to call this first get the value and then call get() for
// current row.
const prevLevel = i > 0 ? view.get(i - 1).level : undefined;
const row = view.get(i);
const currentLevel = row.level;
const level = profile.flamebearer.levels[currentLevel];
// First row is the root and always the total number of ticks.
if (i === 0) {
profile.flamebearer.numTicks = row.value;
}
profile.flamebearer.maxSelf = Math.max(profile.flamebearer.maxSelf, row.self);
if (prevLevel && prevLevel >= currentLevel) {
// we are going back to the previous level and adding sibling we have to figure out new offset
offset = levelWidth(level);
}
if (!level) {
// Starting a new level. Offset is what ever current absolute offset is as there are no siblings yet.
profile.flamebearer.levels[row.level] = [offset, row.value, row.self, getLabel(row.label)];
} else {
// We actually need offset relative to sibling while offset variable contains absolute offset.
const width = levelWidth(level);
level.push(offset - width, row.value, row.self, getLabel(row.label));
}
}
return profile;
}
/**
* Get a width of a level. As offsets are relative to siblings we need to sum all the offsets and values in a level.
* @param level
*/
function levelWidth(level: number[]) {
let length = 0;
for (let i = 0; i < level.length; i += 4) {
const start = level[i];
const value = level[i + 1];
length += start + value;
}
return length;
}

View File

@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { CoreApp, createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
describe('FlameGraphTopTableContainer', () => {
const FlameGraphTopTableContainerWithProps = () => {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData);
return <FlameGraphTopTableContainer data={container} app={CoreApp.Explore} onSymbolClick={jest.fn()} />;
};
it('should render without error', async () => {
expect(() => render(<FlameGraphTopTableContainerWithProps />)).not.toThrow();
});
it('should render correctly', async () => {
// Needed for AutoSizer to work in test
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
render(<FlameGraphTopTableContainerWithProps />);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(16);
const columnHeaders = screen.getAllByRole('columnheader');
expect(columnHeaders).toHaveLength(3);
expect(columnHeaders[0].textContent).toEqual('Symbol');
expect(columnHeaders[1].textContent).toEqual('Self');
expect(columnHeaders[2].textContent).toEqual('Total');
const cells = screen.getAllByRole('cell');
expect(cells).toHaveLength(45); // 16 rows
expect(cells[0].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
expect(cells[1].textContent).toEqual('31.7 K');
expect(cells[2].textContent).toEqual('31.7 Bil');
expect(cells[24].textContent).toEqual('test/pkg/create.(*create).initServer.func2.1');
expect(cells[25].textContent).toEqual('5.58 K');
expect(cells[26].textContent).toEqual('5.58 Bil');
});
});

View File

@ -0,0 +1,140 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { applyFieldOverrides, CoreApp, DataFrame, DataLinkClickEvent, Field, FieldType } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Table, TableSortByFieldState, useStyles2 } from '@grafana/ui';
import { TOP_TABLE_COLUMN_WIDTH } from '../../constants';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { TableData } from '../types';
type Props = {
data: FlameGraphDataContainer;
app: CoreApp;
onSymbolClick: (symbol: string) => void;
height?: number;
};
const FlameGraphTopTableContainer = ({ data, app, onSymbolClick, height }: Props) => {
const styles = useStyles2(getStyles);
const [sort, setSort] = useState<TableSortByFieldState[]>([{ displayName: 'Self', desc: true }]);
return (
<div className={styles.topTableContainer} data-testid="topTable">
<AutoSizer style={{ width: '100%', height }}>
{({ width, height }) => {
if (width < 3 || height < 3) {
return null;
}
const frame = buildTableDataFrame(data, width, onSymbolClick);
return (
<Table
initialSortBy={sort}
onSortByChange={(s) => {
if (s && s.length) {
reportInteraction('grafana_flamegraph_table_sort_selected', {
app,
grafana_version: config.buildInfo.version,
sort: s[0].displayName + '_' + (s[0].desc ? 'desc' : 'asc'),
});
}
setSort(s);
}}
data={frame}
width={width}
height={height}
/>
);
}}
</AutoSizer>
</div>
);
};
function buildTableDataFrame(
data: FlameGraphDataContainer,
width: number,
onSymbolClick: (str: string) => void
): DataFrame {
// Group the data by label
// TODO: should be by filename + funcName + linenumber?
let table: { [key: string]: TableData } = {};
for (let i = 0; i < data.data.length; i++) {
const value = data.getValue(i);
const self = data.getSelf(i);
const label = data.getLabel(i);
table[label] = table[label] || {};
table[label].self = table[label].self ? table[label].self + self : self;
table[label].total = table[label].total ? table[label].total + value : value;
}
const symbolField: Field = {
type: FieldType.string,
name: 'Symbol',
values: [],
config: {
custom: { width: width - TOP_TABLE_COLUMN_WIDTH * 2 },
links: [
{
title: 'Highlight symbol',
url: '',
onClick: (e: DataLinkClickEvent) => {
const field: Field = e.origin.field;
const value = field.values[e.origin.rowIndex];
onSymbolClick(value);
},
},
],
},
};
const selfField: Field = {
type: FieldType.number,
name: 'Self',
values: [],
config: { unit: data.selfField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
};
const totalField: Field = {
type: FieldType.number,
name: 'Total',
values: [],
config: { unit: data.valueField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
};
for (let key in table) {
symbolField.values.push(key);
selfField.values.push(table[key].self);
totalField.values.push(table[key].total);
}
const frame = { fields: [symbolField, selfField, totalField], length: symbolField.values.length };
const dataFrames = applyFieldOverrides({
data: [frame],
fieldConfig: {
defaults: {},
overrides: [],
},
replaceVariables: (value: string) => value,
theme: config.theme2,
});
return dataFrames[0];
}
const getStyles = () => {
return {
topTableContainer: css`
flex-grow: 1;
flex-basis: 50%;
overflow: hidden;
`,
};
};
export default FlameGraphTopTableContainer;

View File

@ -0,0 +1,45 @@
import { LevelItem } from './FlameGraph/dataTransform';
export type ClickedItemData = {
posX: number;
posY: number;
label: string;
item: LevelItem;
level: number;
};
export enum SampleUnit {
Bytes = 'bytes',
Short = 'short',
Nanoseconds = 'ns',
}
export enum ColumnTypes {
Symbol = 'Symbol',
Self = 'Self',
Total = 'Total',
}
export enum SelectedView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
Both = 'both',
}
export interface TableData {
self: number;
total: number;
}
export interface TopTableData {
symbol: string;
self: TopTableValue;
total: TopTableValue;
}
export type TopTableValue = {
value: number;
unitValue: string;
};
export type TextAlign = 'left' | 'right';

View File

@ -0,0 +1,8 @@
export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio;
export const COLLAPSE_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;
export const BAR_TEXT_PADDING_LEFT = 4 * window.devicePixelRatio;
export const MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH = 800;
export const TOP_TABLE_COLUMN_WIDTH = 120;

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 84.64 85.34"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#New_Gradient_Swatch_1);}</style><linearGradient id="New_Gradient_Swatch_1" x1="0" y1="4" x2="84.64" y2="4" gradientTransform="matrix(1, 0, 0, 1, 0, 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Icons"><rect class="cls-3" width="84.64" height="8"/><rect class="cls-2" x="44.06" y="11.05" width="18.28" height="8"/><rect class="cls-1" x="44.06" y="22.1" width="18.28" height="8"/><rect class="cls-2" x="44.06" y="33.14" width="16.28" height="8"/><rect class="cls-1" x="44.06" y="44.19" width="16.28" height="8"/><rect class="cls-2" x="44.06" y="55.24" width="14.23" height="8"/><rect class="cls-1" x="44.06" y="66.29" width="10.28" height="8"/><rect class="cls-2" x="44.06" y="77.34" width="10.28" height="8"/><rect class="cls-2" x="66.83" y="11.05" width="17.81" height="8"/><rect class="cls-1" x="66.83" y="22.1" width="17.81" height="8"/><rect class="cls-2" x="66.83" y="33.14" width="17.81" height="8"/><rect class="cls-1" x="66.83" y="44.19" width="13.81" height="8"/><rect class="cls-2" x="66.83" y="55.24" width="10.81" height="8"/><rect class="cls-2" y="11.05" width="17.28" height="8"/><rect class="cls-1" y="22.1" width="17.28" height="8"/><rect class="cls-2" y="33.14" width="9.28" height="8"/><rect class="cls-1" y="44.19" width="9.28" height="8"/><rect class="cls-2" y="55.24" width="9.28" height="8"/><rect class="cls-1" y="66.29" width="9.28" height="8"/><rect class="cls-2" y="77.34" width="5.28" height="8"/><rect class="cls-2" x="21.53" y="11.05" width="18.28" height="8"/><rect class="cls-1" x="21.53" y="22.1" width="18.28" height="8"/><rect class="cls-2" x="21.53" y="33.16" width="8.55" height="7.96"/><rect class="cls-1" x="21.53" y="44.21" width="8.55" height="7.96"/><rect class="cls-2" x="21.53" y="55.26" width="6.55" height="7.96"/><rect class="cls-1" x="21.53" y="66.31" width="4.55" height="7.96"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB