mirror of https://github.com/grafana/grafana
FlameGraph: Add sandwich view (#70268)
parent
db44ba305e
commit
5ca03a82f0
|
@ -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} /> |
||||
); |
||||
}; |
||||
|
@ -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(); |
||||
}); |
||||
}); |
@ -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; |
@ -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; |
@ -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); |
||||
}); |
||||
}); |
@ -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; |
@ -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, |
||||
}; |
||||
} |
@ -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; |
File diff suppressed because it is too large
Load Diff
@ -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]); |
||||
}); |
||||
}); |
@ -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; |
||||
} |
||||
} |
||||
} |
@ -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 }, |
||||
]); |
||||
}); |
||||
}); |
@ -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}%)`; |
||||
} |
@ -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, |
||||
}; |
File diff suppressed because it is too large
Load Diff
@ -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); |
||||
}); |
||||
}); |
@ -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'); |
||||
} |
@ -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] |
||||
`)
|
||||
); |
||||
}); |
||||
}); |
@ -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; |
||||
} |
@ -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(); |
||||
}); |
||||
}); |
@ -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; |
@ -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); |
||||
}); |
||||
}); |
@ -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; |
@ -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; |
||||
} |
@ -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'); |
||||
}); |
||||
}); |
@ -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; |
@ -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'; |
@ -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; |
After Width: | Height: | Size: 2.1 KiB |
Loading…
Reference in new issue