Plugins: Introduce new Flame graph panel (#56376)

* Flamegraph

* Updated flame graph width/height values

* Fix top table rendering issue

* Add feature toggle for flamegraph in explore

* Update tests

* Hide flamegraph from dash panel viz list if feature toggle not enabled

* Show table if no flameGraphFrames

* Add flame graph to testdata ds

* Minor improvement
This commit is contained in:
Joey Tawadrous 2022-10-07 11:39:14 +01:00 committed by GitHub
parent a18a3d7628
commit 74c809f544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 42688 additions and 9 deletions

View File

@ -7758,6 +7758,9 @@ exports[`better eslint`] = {
"public/app/plugins/panel/debug/RenderInfoViewer.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/flamegraph/components/FlameGraphHeader.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/gauge/GaugeMigrations.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -21,7 +21,7 @@ export enum LoadingState {
}
// Should be kept in sync with grafana-plugin-sdk-go/data/frame_meta.go
export const preferredVisualizationTypes = ['graph', 'table', 'logs', 'trace', 'nodeGraph'] as const;
export const preferredVisualizationTypes = ['graph', 'table', 'logs', 'trace', 'nodeGraph', 'flamegraph'] as const;
export type PreferredVisualisationType = typeof preferredVisualizationTypes[number];
/**

View File

@ -68,6 +68,7 @@ export interface FeatureToggles {
grpcServer?: boolean;
objectStore?: boolean;
traceqlEditor?: boolean;
flameGraph?: boolean;
redshiftAsyncQueryDataSupport?: boolean;
athenaAsyncQueryDataSupport?: boolean;
increaseInMemDatabaseQueryCache?: boolean;

View File

@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/services/secrets/kvstore"
@ -62,6 +63,11 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
continue
}
hideFromList := panel.HideFromList
if panel.ID == "flamegraph" {
hideFromList = !hs.Features.IsEnabled(featuremgmt.FlagFlameGraph)
}
panels[panel.ID] = plugins.PanelDTO{
ID: panel.ID,
Name: panel.Name,
@ -69,7 +75,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
Module: panel.Module,
BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery,
HideFromList: panel.HideFromList,
HideFromList: hideFromList,
ReleaseState: string(panel.State),
Signature: string(panel.Signature),
Sort: getPanelSort(panel.ID),

View File

@ -164,6 +164,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
"candlestick": {},
"news": {},
"nodeGraph": {},
"flamegraph": {},
"traces": {},
"piechart": {},
"stat": {},

View File

@ -65,6 +65,7 @@ func coreTreeList(lib thema.Library) pfs.TreeList {
makeTreeOrPanic("public/app/plugins/panel/bargauge", "bargauge", lib),
makeTreeOrPanic("public/app/plugins/panel/dashlist", "dashlist", lib),
makeTreeOrPanic("public/app/plugins/panel/debug", "debug", lib),
makeTreeOrPanic("public/app/plugins/panel/flamegraph", "flamegraph", lib),
makeTreeOrPanic("public/app/plugins/panel/gauge", "gauge", lib),
makeTreeOrPanic("public/app/plugins/panel/geomap", "geomap", lib),
makeTreeOrPanic("public/app/plugins/panel/gettingstarted", "gettingstarted", lib),

View File

@ -289,6 +289,11 @@ var (
Description: "Show the TraceQL editor in the explore page",
State: FeatureStateAlpha,
},
{
Name: "flameGraph",
Description: "Show the flame graph",
State: FeatureStateAlpha,
},
{
Name: "redshiftAsyncQueryDataSupport",
Description: "Enable async query data support for Redshift",

View File

@ -215,6 +215,10 @@ const (
// Show the TraceQL editor in the explore page
FlagTraceqlEditor = "traceqlEditor"
// FlagFlameGraph
// Show the flame graph
FlagFlameGraph = "flameGraph"
// FlagRedshiftAsyncQueryDataSupport
// Enable async query data support for Redshift
FlagRedshiftAsyncQueryDataSupport = "redshiftAsyncQueryDataSupport"

View File

@ -427,6 +427,42 @@
"signatureType": "",
"signatureOrg": ""
},
{
"name": "Flame Graph",
"type": "panel",
"id": "flamegraph",
"enabled": true,
"pinned": false,
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"description": "",
"links": null,
"logos": {
"small": "public/img/icn-panel.svg",
"large": "public/img/icn-panel.svg"
},
"build": {},
"screenshots": null,
"version": "",
"updated": ""
},
"dependencies": {
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": []
},
"latestVersion": "",
"hasUpdate": false,
"defaultNavUrl": "/plugins/flamegraph/",
"category": "",
"state": "beta",
"signature": "internal",
"signatureType": "",
"signatureOrg": ""
},
{
"name": "Gauge",
"type": "panel",
@ -1702,4 +1738,4 @@
"signatureType": "",
"signatureOrg": ""
}
]
]

View File

@ -39,6 +39,7 @@ const (
serverError500Query queryType = "server_error_500"
logsQuery queryType = "logs"
nodeGraphQuery queryType = "node_graph"
flameGraphQuery queryType = "flame_graph"
rawFrameQuery queryType = "raw_frame"
csvFileQueryType queryType = "csv_file"
csvContentQueryType queryType = "csv_content"
@ -195,6 +196,11 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means
Name: "Node Graph",
})
s.registerScenario(&Scenario{
ID: string(flameGraphQuery),
Name: "Flame Graph",
})
s.registerScenario(&Scenario{
ID: string(rawFrameQuery),
Name: "Raw Frames",

View File

@ -80,6 +80,7 @@ const dummyProps: Props = {
showTable: true,
showTrace: true,
showNodeGraph: true,
showFlameGraph: true,
splitOpen: (() => {}) as any,
changeGraphStyle: () => {},
graphStyle: 'lines',

View File

@ -8,6 +8,7 @@ import { Unsubscribable } from 'rxjs';
import { AbsoluteTimeRange, DataQuery, GrafanaTheme2, LoadingState, QueryFixAction, RawTimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { Collapse, CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2, PanelContainer } from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
import appEvents from 'app/core/app_events';
@ -23,6 +24,7 @@ import { ExploreGraph } from './ExploreGraph';
import { ExploreGraphLabel } from './ExploreGraphLabel';
import ExploreQueryInspector from './ExploreQueryInspector';
import { ExploreToolbar } from './ExploreToolbar';
import { FlameGraphExploreContainer } from './FlameGraphExploreContainer';
import LogsContainer from './LogsContainer';
import { NoData } from './NoData';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
@ -228,15 +230,26 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
renderGraphPanel(width: number) {
const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse, loading, theme, graphStyle } = this.props;
const {
graphResult,
absoluteRange,
timeZone,
splitOpen,
queryResponse,
loading,
theme,
graphStyle,
showFlameGraph,
} = this.props;
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
const label = <ExploreGraphLabel graphStyle={graphStyle} onChangeGraphStyle={this.onChangeGraphStyle} />;
return (
<Collapse label={label} loading={loading} isOpen>
<ExploreGraph
graphStyle={graphStyle}
data={graphResult!}
height={400}
height={showFlameGraph ? 180 : 400}
width={width - spacing}
absoluteRange={absoluteRange}
onChangeTime={this.onUpdateTimeRange}
@ -297,6 +310,11 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
renderFlameGraphPanel() {
const { queryResponse } = this.props;
return <FlameGraphExploreContainer dataFrames={queryResponse.flameGraphFrames} />;
}
renderTraceViewPanel() {
const { queryResponse, splitOpen, exploreId } = this.props;
const dataFrames = queryResponse.series.filter((series) => series.meta?.preferredVisualisationType === 'trace');
@ -330,6 +348,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
showLogs,
showTrace,
showNodeGraph,
showFlameGraph,
timeZone,
} = this.props;
const { openDrawer } = this.state;
@ -344,6 +363,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
queryResponse.logsFrames,
queryResponse.graphFrames,
queryResponse.nodeGraphFrames,
queryResponse.flameGraphFrames,
queryResponse.tableFrames,
queryResponse.traceFrames,
].every((e) => e.length === 0);
@ -391,6 +411,9 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
{showFlameGraph && config.featureToggles.flameGraph && (
<ErrorBoundaryAlert>{this.renderFlameGraphPanel()}</ErrorBoundaryAlert>
)}
{showTrace && <ErrorBoundaryAlert>{this.renderTraceViewPanel()}</ErrorBoundaryAlert>}
{showNoData && <ErrorBoundaryAlert>{this.renderNoData()}</ErrorBoundaryAlert>}
</>
@ -441,6 +464,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
absoluteRange,
queryResponse,
showNodeGraph,
showFlameGraph,
loading,
graphStyle,
} = item;
@ -461,6 +485,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showTable,
showTrace,
showNodeGraph,
showFlameGraph,
loading,
graphStyle,
};

View File

@ -50,6 +50,7 @@ const setup = (propOverrides = {}) => {
tableFrames: [],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
graphResult: null,
logsResult: null,
tableResult: null,

View File

@ -0,0 +1,31 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import FlameGraphContainer from '../../plugins/panel/flamegraph/components/FlameGraphContainer';
interface Props {
dataFrames: DataFrame[];
}
export const FlameGraphExploreContainer = (props: Props) => {
const styles = useStyles2((theme) => getStyles(theme));
return (
<div className={styles.container}>
<FlameGraphContainer data={props.dataFrames[0]} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
background: ${theme.colors.background.primary};
display: flow-root;
padding: ${theme.spacing(1)};
border: 1px solid ${theme.components.panel.borderColor};
border-radius: ${theme.shape.borderRadius(1)};
`,
});

View File

@ -56,6 +56,7 @@ function setup(error: DataQueryError) {
tableFrames: [],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
graphResult: null,
logsResult: null,
tableResult: null,

View File

@ -919,6 +919,7 @@ export const processQueryResponse = (
tableResult,
traceFrames,
nodeGraphFrames,
flameGraphFrames,
} = response;
if (error) {
@ -962,5 +963,6 @@ export const processQueryResponse = (
showTable: !!tableResult,
showTrace: !!traceFrames.length,
showNodeGraph: !!nodeGraphFrames.length,
showFlameGraph: !!flameGraphFrames.length,
};
};

View File

@ -96,6 +96,7 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
tableFrames: [],
graphResult: null,
logsResult: null,

View File

@ -11,6 +11,7 @@ import {
TimeRange,
toDataFrame,
} from '@grafana/data';
import { config } from '@grafana/runtime/src/config';
import { GraphDrawStyle, StackingMode } from '@grafana/schema';
import TableModel from 'app/core/TableModel';
import { ExplorePanelData } from 'app/types';
@ -67,7 +68,19 @@ const getTestContext = () => {
meta: { preferredVisualisationType: 'logs' },
});
return { emptyTable, timeSeries, logs, table };
const flameGraph = toDataFrame({
name: 'flameGraph-res',
refId: 'A',
fields: [
{ name: 'level', type: FieldType.number, values: [4, 5, 6] },
{ name: 'value', type: FieldType.number, values: [100, 100, 100] },
{ name: 'self', type: FieldType.number, values: [10, 10, 10] },
{ name: 'label', type: FieldType.string, values: ['this is a message', 'second message', 'third'] },
],
meta: { preferredVisualisationType: 'flamegraph' },
});
return { emptyTable, timeSeries, logs, table, flameGraph };
};
const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelData => {
@ -83,20 +96,23 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
tableResult: undefined as unknown as null,
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
};
return { ...defaults, ...args };
};
describe('decorateWithGraphLogsTraceAndTable', () => {
describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
it('should correctly classify the dataFrames', () => {
const { table, logs, timeSeries, emptyTable } = getTestContext();
const series = [table, logs, timeSeries, emptyTable];
const { table, logs, timeSeries, emptyTable, flameGraph } = getTestContext();
const series = [table, logs, timeSeries, emptyTable, flameGraph];
const panelData: PanelData = {
series,
state: LoadingState.Done,
timeRange: {} as unknown as TimeRange,
};
// Needed so flamegraph does not fallback to table, will be removed when feature flag no longer necessary
config.featureToggles.flameGraph = true;
expect(decorateWithFrameTypeMetadata(panelData)).toEqual({
series,
@ -107,6 +123,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
logsFrames: [logs],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [flameGraph],
graphResult: null,
tableResult: null,
logsResult: null,
@ -130,6 +147,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
@ -156,6 +174,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
logsFrames: [logs],
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,

View File

@ -30,6 +30,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
const logsFrames: DataFrame[] = [];
const traceFrames: DataFrame[] = [];
const nodeGraphFrames: DataFrame[] = [];
const flameGraphFrames: DataFrame[] = [];
for (const frame of data.series) {
switch (frame.meta?.preferredVisualisationType) {
@ -48,6 +49,9 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
case 'nodeGraph':
nodeGraphFrames.push(frame);
break;
case 'flamegraph':
config.featureToggles.flameGraph ? flameGraphFrames.push(frame) : tableFrames.push(frame);
break;
default:
if (isTimeSeries(frame)) {
graphFrames.push(frame);
@ -66,6 +70,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
logsFrames,
traceFrames,
nodeGraphFrames,
flameGraphFrames,
graphResult: null,
tableResult: null,
logsResult: null,

View File

@ -49,6 +49,7 @@ import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
import * as candlestickPanel from 'app/plugins/panel/candlestick/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
import * as debugPanel from 'app/plugins/panel/debug/module';
import * as flamegraphPanel from 'app/plugins/panel/flamegraph/module';
import * as gaugePanel from 'app/plugins/panel/gauge/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
import * as histogramPanel from 'app/plugins/panel/histogram/module';
@ -133,6 +134,7 @@ const builtInPlugins: any = {
'app/plugins/panel/live/module': livePanel,
'app/plugins/panel/stat/module': statPanel,
'app/plugins/panel/debug/module': debugPanel,
'app/plugins/panel/flamegraph/module': flamegraphPanel,
'app/plugins/panel/gettingstarted/module': gettingStartedPanel,
'app/plugins/panel/gauge/module': gaugePanel,
'app/plugins/panel/piechart/module': pieChartPanel,

View File

@ -114,4 +114,10 @@ export const scenarios = [
name: 'Table Static',
stringInput: '',
},
{
description: '',
id: 'flame_graph',
name: 'Flame Graph',
stringInput: '',
},
];

View File

@ -21,6 +21,7 @@ import { getSearchFilterScopedVar } from 'app/features/variables/utils';
import { queryMetricTree } from './metricTree';
import { generateRandomNodes, savedNodesResponse } from './nodeGraphUtils';
import { runStream } from './runStreams';
import { flameGraphData } from './testData/flameGraphResponse';
import { Scenario, TestDataQuery } from './types';
import { TestDataVariableSupport } from './variables';
@ -66,6 +67,9 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataQuery> {
case 'node_graph':
streams.push(this.nodesQuery(target, options));
break;
case 'flame_graph':
streams.push(this.flameGraphQuery());
break;
case 'raw_frame':
streams.push(this.rawFrameQuery(target, options));
break;
@ -213,6 +217,10 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataQuery> {
return of({ data: frames }).pipe(delay(100));
}
flameGraphQuery(): Observable<DataQueryResponse> {
return of({ data: [flameGraphData] }).pipe(delay(100));
}
rawFrameQuery(target: TestDataQuery, options: DataQueryRequest<TestDataQuery>): Observable<DataQueryResponse> {
try {
const data = JSON.parse(target.rawFrameContent ?? '[]').map((v: any) => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react';
import React, { useState } from 'react';
import { DataFrameView, MutableDataFrame } from '@grafana/data';
import { SelectedView } from '../types';
import FlameGraph from './FlameGraph';
import { Item, nestedSetToLevels } from './dataTransform';
import { data } from './testData/dataNestedSet';
import 'jest-canvas-mock';
jest.mock('react-use', () => ({
useMeasure: () => {
const ref = React.useRef();
return [ref, { width: 1600 }];
},
}));
describe('FlameGraph', () => {
const FlameGraphWithProps = () => {
const [topLevelIndex, setTopLevelIndex] = useState(0);
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [search] = useState('');
const [selectedView, _] = useState(SelectedView.Both);
const flameGraphData = new MutableDataFrame(data);
const dataView = new DataFrameView<Item>(flameGraphData);
const levels = nestedSetToLevels(dataView);
return (
<FlameGraph
data={flameGraphData}
levels={levels}
topLevelIndex={topLevelIndex}
rangeMin={rangeMin}
rangeMax={rangeMax}
search={search}
setTopLevelIndex={setTopLevelIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
selectedView={selectedView}
/>
);
};
it('should render without error', async () => {
expect(() => render(<FlameGraphWithProps />)).not.toThrow();
});
it('should render correctly', async () => {
render(<FlameGraphWithProps />);
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
const ctx = canvas!.getContext('2d');
const calls = ctx!.__getDrawCalls();
expect(calls).toMatchSnapshot();
});
});

View File

@ -0,0 +1,223 @@
// 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, { useCallback, useEffect, useRef, useState } from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, FieldType } from '@grafana/data';
import { PIXELS_PER_LEVEL } from '../../constants';
import { TooltipData, SelectedView } from '../types';
import FlameGraphTooltip, { getTooltipData } from './FlameGraphTooltip';
import { ItemWithStart } from './dataTransform';
import { getBarX, getRectDimensionsForLevel, renderRect } from './rendering';
type Props = {
data: DataFrame;
levels: ItemWithStart[][];
topLevelIndex: number;
rangeMin: number;
rangeMax: number;
search: string;
setTopLevelIndex: (level: number) => void;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
selectedView: SelectedView;
style?: React.CSSProperties;
};
const FlameGraph = ({
data,
levels,
topLevelIndex,
rangeMin,
rangeMax,
search,
setTopLevelIndex,
setRangeMin,
setRangeMax,
selectedView,
}: Props) => {
const styles = getStyles(selectedView);
const totalTicks = data.fields[1].values.get(0);
const valueField =
data.fields.find((f) => f.name === 'value') ?? data.fields.find((f) => f.type === FieldType.number);
const [sizeRef, { width: wrapperWidth }] = useMeasure<HTMLDivElement>();
const graphRef = useRef<HTMLCanvasElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipData, setTooltipData] = useState<TooltipData>();
const [showTooltip, setShowTooltip] = useState(false);
// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to
// the canvas.
const convertPixelCoordinatesToBarCoordinates = useCallback(
(x: number, y: number, pixelsPerTick: number) => {
const levelIndex = Math.floor(y / (PIXELS_PER_LEVEL / window.devicePixelRatio));
const barIndex = getBarIndex(x, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin);
return { levelIndex, barIndex };
},
[levels, totalTicks, rangeMin]
);
const render = useCallback(
(pixelsPerTick: number) => {
if (!levels.length) {
return;
}
const ctx = graphRef.current?.getContext('2d')!;
const graph = graphRef.current!;
const height = PIXELS_PER_LEVEL * levels.length;
graph.width = Math.round(wrapperWidth * window.devicePixelRatio);
graph.height = Math.round(height * window.devicePixelRatio);
graph.style.width = `${wrapperWidth}px`;
graph.style.height = `${height}px`;
ctx.textBaseline = 'middle';
ctx.font = 12 * window.devicePixelRatio + 'px monospace';
ctx.strokeStyle = 'white';
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(level, levelIndex, totalTicks, rangeMin, pixelsPerTick);
for (const rect of dimensions) {
// Render each rectangle based on the computed dimensions
renderRect(ctx, rect, totalTicks, rangeMin, rangeMax, search, levelIndex, topLevelIndex);
}
}
},
[levels, wrapperWidth, totalTicks, rangeMin, rangeMax, search, topLevelIndex]
);
useEffect(() => {
if (graphRef.current) {
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin);
render(pixelsPerTick);
// Clicking allows user to "zoom" into the flamegraph. Zooming means the x axis gets smaller so that the clicked
// bar takes 100% of the x axis.
graphRef.current.onclick = (e) => {
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(e.offsetX, e.offsetY, pixelsPerTick);
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
setTopLevelIndex(levelIndex);
setRangeMin(levels[levelIndex][barIndex].start / totalTicks);
setRangeMax((levels[levelIndex][barIndex].start + levels[levelIndex][barIndex].value) / totalTicks);
}
};
graphRef.current!.onmousemove = (e) => {
if (tooltipRef.current) {
setShowTooltip(false);
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin);
const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(e.offsetX, e.offsetY, pixelsPerTick);
if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
tooltipRef.current.style.left = e.clientX + 10 + 'px';
tooltipRef.current.style.top = e.clientY + 40 + 'px';
const bar = levels[levelIndex][barIndex];
const tooltipData = getTooltipData(valueField!, bar.label, bar.value, totalTicks);
setTooltipData(tooltipData);
setShowTooltip(true);
}
}
};
graphRef.current!.onmouseleave = () => {
setShowTooltip(false);
};
}
}, [
render,
convertPixelCoordinatesToBarCoordinates,
levels,
rangeMin,
rangeMax,
topLevelIndex,
totalTicks,
wrapperWidth,
setTopLevelIndex,
setRangeMin,
setRangeMax,
selectedView,
valueField,
]);
return (
<div className={styles.graph} ref={sizeRef}>
<canvas ref={graphRef} data-testid="flameGraph" />
<FlameGraphTooltip tooltipRef={tooltipRef} tooltipData={tooltipData!} showTooltip={showTooltip} />
</div>
);
};
const getStyles = (selectedView: SelectedView) => ({
graph: css`
cursor: pointer;
float: left;
width: ${selectedView === SelectedView.FlameGraph ? '100%' : '50%'};
`,
});
/**
* 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: ItemWithStart[],
pixelsPerTick: number,
totalTicks: number,
rangeMin: number
) => {
if (level) {
let start = 0;
let end = level.length - 1;
while (start <= end) {
const midIndex = (start + end) >> 1;
const startOfBar = getBarX(level[midIndex].start, totalTicks, rangeMin, pixelsPerTick);
const startOfNextBar = getBarX(
level[midIndex].start + level[midIndex].value,
totalTicks,
rangeMin,
pixelsPerTick
);
if (startOfBar <= x && startOfNextBar >= x) {
return midIndex;
}
if (startOfBar > x) {
end = midIndex - 1;
} else {
start = midIndex + 1;
}
}
}
return -1;
};
export default FlameGraph;

View File

@ -0,0 +1,86 @@
import { ArrayVector, Field, FieldType } from '@grafana/data';
import { getTooltipData } from './FlameGraphTooltip';
describe('should get tooltip data correctly', () => {
it('for bytes', () => {
const tooltipData = getTooltipData(makeField('bytes'), 'total', 8_624_078_250, 8_624_078_250);
expect(tooltipData).toEqual({
name: 'total',
percentTitle: '% of total',
percentValue: 100,
unitTitle: 'RAM',
unitValue: '8.03 GiB',
samples: '8,624,078,250',
});
});
it('with none unit', () => {
const tooltipData = getTooltipData(makeField('none'), 'total', 8_624_078_250, 8_624_078_250);
expect(tooltipData).toEqual({
name: 'total',
percentTitle: '% of total',
percentValue: 100,
unitTitle: 'Count',
unitValue: '8624078250',
samples: '8,624,078,250',
});
});
it('without unit', () => {
const tooltipData = getTooltipData(
{
name: 'test',
type: FieldType.number,
values: new ArrayVector(),
config: {},
},
'total',
8_624_078_250,
8_624_078_250
);
expect(tooltipData).toEqual({
name: 'total',
percentTitle: '% of total',
percentValue: 100,
unitTitle: 'Count',
unitValue: '8624078250',
samples: '8,624,078,250',
});
});
it('for objects', () => {
const tooltipData = getTooltipData(makeField('short'), 'total', 8_624_078_250, 8_624_078_250);
expect(tooltipData).toEqual({
name: 'total',
percentTitle: '% of total',
percentValue: 100,
unitTitle: 'Count',
unitValue: '8.62 Bil',
samples: '8,624,078,250',
});
});
it('for nanoseconds', () => {
const tooltipData = getTooltipData(makeField('ns'), 'total', 8_624_078_250, 8_624_078_250);
expect(tooltipData).toEqual({
name: 'total',
percentTitle: '% of total time',
percentValue: 100,
unitTitle: 'Time',
unitValue: '8.62 s',
samples: '8,624,078,250',
});
});
});
function makeField(unit: string): Field {
return {
name: 'test',
type: FieldType.number,
config: {
unit,
},
values: new ArrayVector(),
};
}

View File

@ -0,0 +1,94 @@
import { css } from '@emotion/css';
import React, { LegacyRef } from 'react';
import { createTheme, Field, getDisplayProcessor } from '@grafana/data';
import { useStyles, Tooltip } from '@grafana/ui';
import { TooltipData, SampleUnit } from '../types';
type Props = {
tooltipRef: LegacyRef<HTMLDivElement>;
tooltipData: TooltipData;
showTooltip: boolean;
};
const FlameGraphTooltip = ({ tooltipRef, tooltipData, showTooltip }: Props) => {
const styles = useStyles(getStyles);
return (
<div ref={tooltipRef} className={styles.tooltip}>
{tooltipData && showTooltip && (
<Tooltip
content={
<div>
<div className={styles.name}>{tooltipData.name}</div>
<div>
{tooltipData.percentTitle}: <b>{tooltipData.percentValue}%</b>
</div>
<div>
{tooltipData.unitTitle}: <b>{tooltipData.unitValue}</b>
</div>
<div>
Samples: <b>{tooltipData.samples}</b>
</div>
</div>
}
placement={'right'}
show={true}
>
<span></span>
</Tooltip>
)}
</div>
);
};
export const getTooltipData = (field: Field, label: string, value: number, totalTicks: number): TooltipData => {
let samples = value;
let percentTitle = '';
let unitTitle = '';
const processor = getDisplayProcessor({ field, theme: createTheme() /* theme does not matter for us here */ });
const displayValue = processor(value);
const percent = Math.round(10000 * (samples / totalTicks)) / 100;
let unitValue = displayValue.text + displayValue.suffix;
switch (field.config.unit) {
case SampleUnit.Bytes:
percentTitle = '% of total';
unitTitle = 'RAM';
break;
case SampleUnit.Nanoseconds:
percentTitle = '% of total time';
unitTitle = 'Time';
break;
default:
percentTitle = '% of total';
unitTitle = 'Count';
if (!displayValue.suffix) {
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
unitValue = displayValue.text;
}
break;
}
return {
name: label,
percentTitle: percentTitle,
percentValue: percent,
unitTitle: unitTitle,
unitValue,
samples: samples.toLocaleString(),
};
};
const getStyles = () => ({
tooltip: css`
position: fixed;
`,
name: css`
margin-bottom: 10px;
`,
});
export default FlameGraphTooltip;

View File

@ -0,0 +1,52 @@
import { DataFrameView, MutableDataFrame } from '@grafana/data';
import { Item, nestedSetToLevels } from './dataTransform';
describe('nestedSetToLevels', () => {
it('converts nested set data frame to levels', () => {
const frame = new MutableDataFrame({
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'] },
],
});
const levels = nestedSetToLevels(new DataFrameView<Item>(frame));
expect(levels).toEqual([
[{ level: 0, value: 10, start: 0, label: '1' }],
[
{ level: 1, value: 5, start: 0, label: '2' },
{ level: 1, value: 4, start: 5, label: '6' },
],
[
{ level: 2, value: 3, start: 0, label: '3' },
{ level: 2, value: 1, start: 3, label: '5' },
{ level: 2, value: 3, start: 5, label: '7' },
],
[
{ level: 3, value: 1, start: 0, label: '4' },
{ level: 3, value: 2, start: 5, label: '8' },
],
[{ level: 4, value: 1, start: 5, label: '9' }],
]);
});
it('converts nested set data if multiple same level items', () => {
const frame = new MutableDataFrame({
fields: [
{ name: 'level', values: [0, 1, 1, 1] },
{ name: 'value', values: [10, 5, 3, 1] },
{ name: 'label', values: ['1', '2', '3', '4'] },
],
});
const levels = nestedSetToLevels(new DataFrameView<Item>(frame));
expect(levels).toEqual([
[{ level: 0, value: 10, start: 0, label: '1' }],
[
{ level: 1, value: 5, start: 0, label: '2' },
{ level: 1, value: 3, start: 5, label: '3' },
{ level: 1, value: 1, start: 8, label: '4' },
],
]);
});
});

View File

@ -0,0 +1,35 @@
import { DataFrameView } from '@grafana/data';
export type Item = { level: number; value: number; label: string; self: number };
export type ItemWithStart = Item & { start: number };
/**
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
* rendering code.
* @param dataView
*/
export function nestedSetToLevels(dataView: DataFrameView<Item>): ItemWithStart[][] {
const levels: ItemWithStart[][] = [];
let offset = 0;
for (let i = 0; i < dataView.length; i++) {
// We have to clone the items as .get(i) returns a changing pointer not the data themselves.
const item = { ...dataView.get(i) };
const prevItem = i > 0 ? { ...dataView.get(i - 1) } : undefined;
levels[item.level] = levels[item.level] || [];
if (prevItem && prevItem.level >= item.level) {
// 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 lastItem = levels[item.level][levels[item.level].length - 1];
offset = lastItem.start + lastItem.value;
}
const newItem: ItemWithStart = {
...item,
start: offset,
};
levels[item.level].push(newItem);
}
return levels;
}

View File

@ -0,0 +1,47 @@
import { ItemWithStart } from './dataTransform';
import { getRectDimensionsForLevel } from './rendering';
describe('getRectDimensionsForLevel', () => {
it('should render a single item', () => {
const level: ItemWithStart[] = [{ level: 1, start: 0, value: 100, label: '1', self: 0 }];
const result = getRectDimensionsForLevel(level, 1, 100, 0, 10);
expect(result).toEqual([
{
width: 999,
height: 22,
x: 0,
y: 22,
collapsed: false,
ticks: 100,
label: '1',
},
]);
});
it('should render a multiple items', () => {
const level: ItemWithStart[] = [
{ level: 2, start: 0, value: 100, label: '1', self: 0 },
{ level: 2, start: 100, value: 50, label: '2', self: 0 },
{ level: 2, start: 150, value: 50, label: '3', self: 0 },
];
const result = getRectDimensionsForLevel(level, 2, 100, 0, 10);
expect(result).toEqual([
{ width: 999, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1' },
{ width: 499, height: 22, x: 1000, y: 44, collapsed: false, ticks: 50, label: '2' },
{ width: 499, height: 22, x: 1500, y: 44, collapsed: false, ticks: 50, label: '3' },
]);
});
it('should render a collapsed items', () => {
const level: ItemWithStart[] = [
{ level: 2, start: 0, value: 100, label: '1', self: 0 },
{ level: 2, start: 100, value: 2, label: '2', self: 0 },
{ level: 2, start: 102, value: 1, label: '3', self: 0 },
];
const result = getRectDimensionsForLevel(level, 2, 100, 0, 1);
expect(result).toEqual([
{ width: 99, height: 22, x: 0, y: 44, collapsed: false, ticks: 100, label: '1' },
{ width: 3, height: 22, x: 100, y: 44, collapsed: true, ticks: 3, label: '2' },
]);
});
});

View File

@ -0,0 +1,134 @@
import { colors, fuzzyMatch } from '@grafana/ui';
import {
BAR_BORDER_WIDTH,
BAR_TEXT_PADDING_LEFT,
COLLAPSE_THRESHOLD,
HIDE_THRESHOLD,
LABEL_THRESHOLD,
PIXELS_PER_LEVEL,
} from '../../constants';
import { ItemWithStart } from './dataTransform';
type RectData = {
width: number;
height: number;
x: number;
y: number;
collapsed: boolean;
ticks: number;
label: string;
};
/**
* 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.
* @param level
* @param levelIndex
* @param totalTicks
* @param rangeMin
* @param pixelsPerTick
*/
export function getRectDimensionsForLevel(
level: ItemWithStart[],
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 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: item.label,
});
}
return coordinatesLevel;
}
export function renderRect(
ctx: CanvasRenderingContext2D,
rect: RectData,
totalTicks: number,
rangeMin: number,
rangeMax: number,
query: string,
levelIndex: number,
topLevelIndex: number
) {
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;
const queryResult = query && fuzzyMatch(name.toLowerCase(), query.toLowerCase()).found;
if (!rect.collapsed) {
ctx.stroke();
if (query) {
ctx.fillStyle = queryResult ? getBarColor(h, l) : colors[55];
} else {
ctx.fillStyle = levelIndex > topLevelIndex - 1 ? getBarColor(h, l) : getBarColor(h, l + 15);
}
} else {
ctx.fillStyle = queryResult ? getBarColor(h, l) : colors[55];
}
ctx.fill();
if (!rect.collapsed && rect.width >= LABEL_THRESHOLD) {
ctx.save();
ctx.clip(); // so text does not overflow
ctx.fillStyle = '#222';
ctx.fillText(`${name}`, Math.max(rect.x, 0) + BAR_TEXT_PADDING_LEFT, rect.y + PIXELS_PER_LEVEL / 2);
ctx.restore();
}
}
/**
* Returns the X position of the bar. totalTicks * rangeMin is to adjust for any current zoom. So if we zoom to a
* section of the graph we align and shift the X coordinates accordingly.
* @param offset
* @param totalTicks
* @param rangeMin
* @param pixelsPerTick
*/
export function getBarX(offset: number, totalTicks: number, rangeMin: number, pixelsPerTick: number) {
return (offset - totalTicks * rangeMin) * pixelsPerTick;
}
function getBarColor(h: number, l: number) {
return `hsl(${h}, 100%, ${l}%)`;
}

View File

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

View File

@ -0,0 +1,90 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MutableDataFrame } 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 });
const FlameGraphContainerWithProps = () => {
const flameGraphData = new MutableDataFrame(data);
flameGraphData.meta = {
custom: {
ProfileTypeID: 'cpu:foo:bar',
},
};
return <FlameGraphContainer data={flameGraphData} />;
};
it('should render without error', async () => {
expect(() => render(<FlameGraphContainerWithProps />)).not.toThrow();
});
it('should update search when row selected in top table', async () => {
render(<FlameGraphContainerWithProps />);
screen.getAllByRole('row')[1].click();
expect(screen.getByDisplayValue('net/http.HandlerFunc.ServeHTTP')).toBeInTheDocument();
screen.getAllByRole('row')[2].click();
expect(screen.getByDisplayValue('total')).toBeInTheDocument();
screen.getAllByRole('row')[2].click();
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();
screen.getByText(/Top Table/).click();
expect(screen.queryByTestId('flameGraph')).toBeNull();
expect(screen.getByTestId('topTable')).toBeDefined();
screen.getByText(/Flame Graph/).click();
expect(screen.getByTestId('flameGraph')).toBeDefined();
expect(screen.queryByTestId('topTable')).toBeNull();
screen.getByText(/Both/).click();
expect(screen.getByTestId('flameGraph')).toBeDefined();
expect(screen.getByTestId('topTable')).toBeDefined();
});
it('should render both option if screen width >= threshold', async () => {
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH;
global.dispatchEvent(new Event('resize')); // Trigger the window resize event
render(<FlameGraphContainerWithProps />);
expect(screen.getByText(/Both/)).toBeDefined();
});
it('should not render both option if screen width < threshold', async () => {
global.innerWidth = MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH - 1;
global.dispatchEvent(new Event('resize'));
render(<FlameGraphContainerWithProps />);
expect(screen.queryByTestId(/Both/)).toBeNull();
});
});

View File

@ -0,0 +1,92 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, DataFrameView } from '@grafana/data';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
import FlameGraph from './FlameGraph/FlameGraph';
import { Item, nestedSetToLevels } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { SelectedView } from './types';
type Props = {
data: DataFrame;
};
const FlameGraphContainer = (props: Props) => {
const [topLevelIndex, setTopLevelIndex] = useState(0);
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>();
// Transform dataFrame with nested set format to array of levels. Each level contains all the bars for a particular
// level of the flame graph. We do this temporary as in the end we should be able to render directly by iterating
// over the dataFrame rows.
const levels = useMemo(() => {
if (!props.data) {
return [];
}
const dataView = new DataFrameView<Item>(props.data);
return nestedSetToLevels(dataView);
}, [props.data]);
// 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]);
return (
<div ref={sizeRef}>
<FlameGraphHeader
setTopLevelIndex={setTopLevelIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
search={search}
setSearch={setSearch}
selectedView={selectedView}
setSelectedView={setSelectedView}
containerWidth={containerWidth}
/>
{selectedView !== SelectedView.FlameGraph && (
<FlameGraphTopTableContainer
data={props.data}
totalLevels={levels.length}
selectedView={selectedView}
search={search}
setSearch={setSearch}
setTopLevelIndex={setTopLevelIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
/>
)}
{selectedView !== SelectedView.TopTable && (
<FlameGraph
data={props.data}
levels={levels}
topLevelIndex={topLevelIndex}
rangeMin={rangeMin}
rangeMax={rangeMax}
search={search}
setTopLevelIndex={setTopLevelIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
selectedView={selectedView}
/>
)}
</div>
);
};
export default FlameGraphContainer;

View File

@ -0,0 +1,35 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import FlameGraphHeader from './FlameGraphHeader';
import { SelectedView } from './types';
describe('FlameGraphHeader', () => {
const FlameGraphHeaderWithProps = () => {
const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Both);
return (
<FlameGraphHeader
search={search}
setSearch={setSearch}
setTopLevelIndex={jest.fn()}
setRangeMin={jest.fn()}
setRangeMax={jest.fn()}
selectedView={selectedView}
setSelectedView={setSelectedView}
containerWidth={1600}
/>
);
};
it('reset button should remove search text', async () => {
render(<FlameGraphHeaderWithProps />);
await userEvent.type(screen.getByPlaceholderText('Search..'), 'abc');
expect(screen.getByDisplayValue('abc')).toBeInTheDocument();
screen.getByRole('button', { name: /Reset/i }).click();
expect(screen.queryByDisplayValue('abc')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,104 @@
import { css } from '@emotion/css';
import React from 'react';
import { Button, Input, useStyles, RadioButtonGroup } from '@grafana/ui';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from '../constants';
import { SelectedView } from './types';
type Props = {
search: string;
setTopLevelIndex: (level: number) => void;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
setSearch: (search: string) => void;
selectedView: SelectedView;
setSelectedView: (view: SelectedView) => void;
containerWidth: number;
};
const FlameGraphHeader = ({
search,
setTopLevelIndex,
setRangeMin,
setRangeMax,
setSearch,
selectedView,
setSelectedView,
containerWidth,
}: Props) => {
const styles = useStyles(getStyles);
let viewOptions: Array<{ value: string; 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 (containerWidth >= 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 (
<div className={styles.header}>
<div className={styles.leftContainer}>
<div className={styles.inputContainer}>
<Input
value={search || ''}
onChange={(v) => {
setSearch(v.currentTarget.value);
}}
placeholder={'Search..'}
width={24}
/>
</div>
<Button
type={'button'}
variant={'secondary'}
size={'md'}
onClick={() => {
setTopLevelIndex(0);
setRangeMin(0);
setRangeMax(1);
setSearch('');
}}
>
Reset View
</Button>
</div>
<div className={styles.rightContainer}>
<RadioButtonGroup
options={viewOptions}
value={selectedView}
onChange={(view) => {
setSelectedView(view as SelectedView);
}}
/>
</div>
</div>
);
};
const getStyles = () => ({
header: css`
display: flow-root;
padding: 0 0 20px 0;
width: 100%;
`,
inputContainer: css`
float: left;
margin-right: 20px;
`,
leftContainer: css`
float: left;
`,
rightContainer: css`
float: right;
`,
});
export default FlameGraphHeader;

View File

@ -0,0 +1,257 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useMemo } from 'react';
import { SortByFn, useSortBy, useAbsoluteLayout, useTable, CellProps } from 'react-table';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2, CustomScrollbar } from '@grafana/ui';
import { TOP_TABLE_COLUMN_WIDTH } from '../../constants';
import { ColumnTypes, TopTableData, TopTableValue } from '../types';
type Props = {
width: number;
height: number;
data: TopTableData[];
search: string;
setSearch: (search: string) => void;
setTopLevelIndex: (level: number) => void;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
};
const FlameGraphTopTable = ({
width,
height,
data,
search,
setSearch,
setTopLevelIndex,
setRangeMin,
setRangeMax,
}: Props) => {
const styles = useStyles2((theme) => getStyles(theme));
const sortSymbols: SortByFn<object> = (a, b, column) => {
return a.values[column].localeCompare(b.values[column]);
};
const sortUnits: SortByFn<object> = (a, b, column) => {
return a.values[column].value.toString().localeCompare(b.values[column].value.toString(), 'en', { numeric: true });
};
const columns = useMemo(
() => [
{
accessor: ColumnTypes.Symbol.toLowerCase(),
header: ColumnTypes.Symbol,
cell: SymbolCell,
sortType: sortSymbols,
width: width - TOP_TABLE_COLUMN_WIDTH * 2,
},
{
accessor: ColumnTypes.Self.toLowerCase(),
header: ColumnTypes.Self,
cell: UnitCell,
sortType: sortUnits,
width: TOP_TABLE_COLUMN_WIDTH,
},
{
accessor: ColumnTypes.Total.toLowerCase(),
header: ColumnTypes.Total,
cell: UnitCell,
sortType: sortUnits,
width: TOP_TABLE_COLUMN_WIDTH,
},
],
[width]
);
const options = useMemo(
() => ({
columns,
data,
initialState: {
sortBy: [
{
id: ColumnTypes.Self.toLowerCase(),
desc: true,
},
],
},
}),
[columns, data]
);
const rowClicked = useCallback(
(row: string) => {
if (search === row) {
setSearch('');
} else {
setSearch(row);
// Reset selected level in flamegraph when selecting row in top table
setTopLevelIndex(0);
setRangeMin(0);
setRangeMax(1);
}
},
[search, setRangeMax, setRangeMin, setSearch, setTopLevelIndex]
);
const { headerGroups, rows, prepareRow } = useTable(options, useSortBy, useAbsoluteLayout);
const renderRow = React.useCallback(
({ index, style }) => {
let row = rows[index];
prepareRow(row);
const rowValue = row.values[ColumnTypes.Symbol.toLowerCase()];
const classNames = cx(rowValue === search && styles.matchedRow, styles.row);
return (
<div
{...row.getRowProps({ style })}
className={classNames}
onClick={() => {
rowClicked(rowValue);
}}
>
{row.cells.map((cell) => {
const { key, ...cellProps } = cell.getCellProps();
if (cellProps.style) {
cellProps.style.minWidth = cellProps.style.width;
}
return (
<div key={key} className={styles.cell} {...cellProps}>
{cell.render('cell')}
</div>
);
})}
</div>
);
},
[rows, prepareRow, search, styles.matchedRow, styles.row, styles.cell, rowClicked]
);
return (
<div className={styles.table(height)} data-testid="topTable">
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
return (
<div key={key} className={styles.header} {...headerGroupProps}>
{headerGroup.headers.map((column) => {
const { key, ...headerProps } = column.getHeaderProps(
column.canSort ? column.getSortByToggleProps() : undefined
);
return (
<div key={key} className={styles.headerCell} {...headerProps}>
{column.render('header')}
{column.isSorted && <Icon name={column.isSortedDesc ? 'arrow-down' : 'arrow-up'} />}
</div>
);
})}
</div>
);
})}
{rows.length > 0 ? (
<CustomScrollbar hideVerticalTrack={true}>
<FixedSizeList
height={height}
itemCount={rows.length}
itemSize={38}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{renderRow}
</FixedSizeList>
</CustomScrollbar>
) : (
<div style={{ height: height }} className={styles.noData}>
No data
</div>
)}
</div>
);
};
const SymbolCell = ({ cell: { value } }: CellProps<TopTableValue, TopTableValue>) => {
return <div>{value}</div>;
};
const UnitCell = ({ cell: { value } }: CellProps<TopTableValue, TopTableValue>) => {
return <div>{value.unitValue}</div>;
};
const getStyles = (theme: GrafanaTheme2) => ({
table: (height: number) => {
return css`
background-color: ${theme.colors.background.primary};
height: ${height}px;
overflow: scroll;
display: flex;
flex-direction: column;
width: 100%;
`;
},
header: css`
height: 38px;
& > :nth-child(2),
& > :nth-child(3) {
text-align: right;
}
`,
headerCell: css`
background-color: ${theme.colors.background.secondary};
color: ${theme.colors.primary.text};
padding: ${theme.spacing(1)};
`,
matchedRow: css`
& > :nth-child(1),
& > :nth-child(2),
& > :nth-child(3) {
background-color: ${theme.colors.background.secondary} !important;
}
`,
row: css`
border-top: 1px solid ${theme.components.panel.borderColor};
&:hover {
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
}
& > :nth-child(2),
& > :nth-child(3) {
text-align: right;
}
& > :nth-child(3) {
border-right: none;
}
`,
cell: css`
border-right: 1px solid ${theme.components.panel.borderColor};
padding: ${theme.spacing(1)};
div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
overflow: visible;
width: auto !important;
box-shadow: 0 0 2px ${theme.colors.primary.main};
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
z-index: 1;
}
`,
noData: css`
align-items: center;
display: flex;
justify-content: center;
`,
});
export default FlameGraphTopTable;

View File

@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import React, { useState } from 'react';
import { DataFrameView, MutableDataFrame } from '@grafana/data';
import { Item, nestedSetToLevels } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { SelectedView } from '../types';
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
describe('FlameGraphTopTableContainer', () => {
const FlameGraphTopTableContainerWithProps = () => {
const [search, setSearch] = useState('');
const [selectedView, _] = useState(SelectedView.Both);
const flameGraphData = new MutableDataFrame(data);
const dataView = new DataFrameView<Item>(flameGraphData);
const levels = nestedSetToLevels(dataView);
return (
<FlameGraphTopTableContainer
data={flameGraphData}
totalLevels={levels.length}
selectedView={selectedView}
search={search}
setSearch={setSearch}
setTopLevelIndex={jest.fn()}
setRangeMin={jest.fn()}
setRangeMax={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(17); // + 1 for the columnHeaders
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(48); // 16 rows
expect(cells[0].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
expect(cells[1].textContent).toEqual('31.7 K');
expect(cells[2].textContent).toEqual('31.7 Bil');
expect(cells[24].textContent).toEqual('test/pkg/create.(*create).initServer.func2.1');
expect(cells[25].textContent).toEqual('5.58 K');
expect(cells[26].textContent).toEqual('5.58 Bil');
});
});

View File

@ -0,0 +1,138 @@
import { css } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { createTheme, DataFrame, Field, FieldType, getDisplayProcessor } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { PIXELS_PER_LEVEL } from '../../constants';
import { SampleUnit, SelectedView, TableData, TopTableData } from '../types';
import FlameGraphTopTable from './FlameGraphTopTable';
type Props = {
data: DataFrame;
totalLevels: number;
selectedView: SelectedView;
search: string;
setSearch: (search: string) => void;
setTopLevelIndex: (level: number) => void;
setRangeMin: (range: number) => void;
setRangeMax: (range: number) => void;
};
const FlameGraphTopTableContainer = ({
data,
totalLevels,
selectedView,
search,
setSearch,
setTopLevelIndex,
setRangeMin,
setRangeMax,
}: Props) => {
const styles = useStyles2(() => getStyles(selectedView));
const [topTable, setTopTable] = useState<TopTableData[]>();
const valueField =
data.fields.find((f) => f.name === 'value') ?? data.fields.find((f) => f.type === FieldType.number);
const selfField = data.fields.find((f) => f.name === 'self') ?? data.fields.find((f) => f.type === FieldType.number);
const sortLevelsIntoTable = useCallback(() => {
let label, self, value;
let table: { [key: string]: TableData } = {};
if (data.fields.length === 4) {
const valueValues = data.fields[1].values;
const selfValues = data.fields[2].values;
const labelValues = data.fields[3].values;
for (let i = 0; i < valueValues.length; i++) {
value = valueValues.get(i);
self = selfValues.get(i);
label = labelValues.get(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;
}
}
return table;
}, [data.fields]);
const getTopTableData = (field: Field, value: number) => {
const processor = getDisplayProcessor({ field, theme: createTheme() /* theme does not matter for us here */ });
const displayValue = processor(value);
let unitValue = displayValue.text + displayValue.suffix;
switch (field.config.unit) {
case SampleUnit.Bytes:
break;
case SampleUnit.Nanoseconds:
break;
default:
if (!displayValue.suffix) {
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
unitValue = displayValue.text;
}
break;
}
return unitValue;
};
useEffect(() => {
const table = sortLevelsIntoTable();
let topTable: TopTableData[] = [];
for (let key in table) {
const selfUnit = getTopTableData(selfField!, table[key].self);
const valueUnit = getTopTableData(valueField!, table[key].total);
topTable.push({
symbol: key,
self: { value: table[key].self, unitValue: selfUnit },
total: { value: table[key].total, unitValue: valueUnit },
});
}
setTopTable(topTable);
}, [data.fields, selfField, sortLevelsIntoTable, valueField]);
return (
<>
{topTable && (
<div className={styles.topTableContainer}>
<AutoSizer style={{ width: '100%', height: PIXELS_PER_LEVEL * totalLevels + 'px' }}>
{({ width, height }) => (
<FlameGraphTopTable
width={width}
height={height}
data={topTable}
search={search}
setSearch={setSearch}
setTopLevelIndex={setTopLevelIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
/>
)}
</AutoSizer>
</div>
)}
</>
);
};
const getStyles = (selectedView: SelectedView) => {
const marginRight = '20px';
return {
topTableContainer: css`
cursor: pointer;
float: left;
margin-right: ${marginRight};
width: ${selectedView === SelectedView.TopTable ? '100%' : `calc(50% - ${marginRight})`};
`,
};
};
export default FlameGraphTopTableContainer;

View File

@ -0,0 +1,42 @@
export type TooltipData = {
name: string;
percentTitle: string;
percentValue: number;
unitTitle: string;
unitValue: string;
samples: string;
};
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;
};

View File

@ -0,0 +1,8 @@
export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio;
export const COLLAPSE_THRESHOLD = 10 * window.devicePixelRatio;
export const HIDE_THRESHOLD = 0.5 * window.devicePixelRatio;
export const LABEL_THRESHOLD = 20 * window.devicePixelRatio;
export const BAR_BORDER_WIDTH = 0.5 * window.devicePixelRatio;
export const BAR_TEXT_PADDING_LEFT = 4 * window.devicePixelRatio;
export const MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH = 800;
export const TOP_TABLE_COLUMN_WIDTH = 120;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { PanelPlugin, PanelProps } from '@grafana/data';
import FlameGraphContainer from './components/FlameGraphContainer';
export const FlameGraphPanel: React.FunctionComponent<PanelProps> = (props) => {
return <FlameGraphContainer data={props.data.series[0]} />;
};
export const plugin = new PanelPlugin(FlameGraphPanel);

View File

@ -0,0 +1,17 @@
{
"type": "panel",
"name": "Flame Graph",
"id": "flamegraph",
"state": "beta",
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "public/img/icn-panel.svg",
"large": "public/img/icn-panel.svg"
}
}
}

View File

@ -160,6 +160,7 @@ export interface ExploreItemState {
showTable?: boolean;
showTrace?: boolean;
showNodeGraph?: boolean;
showFlameGraph?: boolean;
/**
* History of all queries
@ -227,6 +228,7 @@ export interface ExplorePanelData extends PanelData {
logsFrames: DataFrame[];
traceFrames: DataFrame[];
nodeGraphFrames: DataFrame[];
flameGraphFrames: DataFrame[];
graphResult: DataFrame[] | null;
tableResult: DataFrame | null;
logsResult: LogsModel | null;