mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Jaeger: Add node graph view for trace (#31521)
* add default arc and don't display stats background without any stats * Add node graph transform * Use coloring scheme for the node graph * Fix type * Add tests * Fix and update test * Fix strict ts errors * Fix ref handling * Update test data to connect spans to a parent
This commit is contained in:
parent
39a3b0d0b0
commit
c16083fcf5
@ -212,7 +212,7 @@ export function getFieldColorModeForField(field: Field): FieldColorMode {
|
||||
}
|
||||
|
||||
/** @beta */
|
||||
export function getFieldColorMode(mode?: FieldColorModeId): FieldColorMode {
|
||||
export function getFieldColorMode(mode?: FieldColorModeId | string): FieldColorMode {
|
||||
return fieldColorModeRegistry.get(mode ?? FieldColorModeId.Thresholds);
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@ export enum FieldColorModeId {
|
||||
*/
|
||||
export interface FieldColor {
|
||||
/** The main color scheme mode */
|
||||
mode: FieldColorModeId;
|
||||
mode: FieldColorModeId | string;
|
||||
/** Stores the fixed color value if mode is fixed */
|
||||
fixedColor?: string;
|
||||
/** Some visualizations need to know how to assign a series color from by value color schemes */
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,10 @@
|
||||
import React, { MouseEvent, memo } from 'react';
|
||||
import { NodeDatum } from './types';
|
||||
import { stylesFactory, useTheme } from '../../themes';
|
||||
import { getColorForTheme, GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import cx from 'classnames';
|
||||
import { getColorForTheme, GrafanaTheme } from '@grafana/data';
|
||||
import { NodeDatum } from './types';
|
||||
import { stylesFactory, useTheme } from '../../themes';
|
||||
|
||||
const nodeR = 40;
|
||||
|
||||
@ -33,6 +34,22 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.6).toHex8String()};
|
||||
width: 100px;
|
||||
`,
|
||||
|
||||
statsText: css`
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 70px;
|
||||
`,
|
||||
|
||||
textHovering: css`
|
||||
width: 200px;
|
||||
& span {
|
||||
background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.8).toHex8String()};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
@ -66,19 +83,25 @@ export const Node = memo(function Node(props: {
|
||||
>
|
||||
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
|
||||
{hovering && <circle className={styles.hoverCircle} r={nodeR - 3} cx={node.x} cy={node.y} strokeWidth={2} />}
|
||||
<ResponseTypeCircle node={node} />
|
||||
<ColorCircle node={node} />
|
||||
<g className={styles.text}>
|
||||
<text x={node.x} y={node.y - 5} textAnchor={'middle'}>
|
||||
{node.mainStat}
|
||||
</text>
|
||||
<text x={node.x} y={node.y + 10} textAnchor={'middle'}>
|
||||
{node.secondaryStat}
|
||||
</text>
|
||||
<foreignObject x={node.x - 50} y={node.y + nodeR + 5} width="100" height="30">
|
||||
<div className={styles.titleText}>
|
||||
{node.title}
|
||||
<foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="30">
|
||||
<div className={cx(styles.statsText, hovering && styles.textHovering)}>
|
||||
<span>{node.mainStat}</span>
|
||||
<br />
|
||||
{node.subTitle}
|
||||
<span>{node.secondaryStat}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<foreignObject
|
||||
x={node.x - (hovering ? 100 : 50)}
|
||||
y={node.y + nodeR + 5}
|
||||
width={hovering ? '200' : '100'}
|
||||
height="30"
|
||||
>
|
||||
<div className={cx(styles.titleText, hovering && styles.textHovering)}>
|
||||
<span>{node.title}</span>
|
||||
<br />
|
||||
<span>{node.subTitle}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
@ -87,9 +110,9 @@ export const Node = memo(function Node(props: {
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the outer segmented circle with different color for each response type.
|
||||
* Shows the outer segmented circle with different colors based on the supplied data.
|
||||
*/
|
||||
function ResponseTypeCircle(props: { node: NodeDatum }) {
|
||||
function ColorCircle(props: { node: NodeDatum }) {
|
||||
const { node } = props;
|
||||
const fullStat = node.arcSections.find((s) => s.value === 1);
|
||||
const theme = useTheme();
|
||||
@ -109,6 +132,10 @@ function ResponseTypeCircle(props: { node: NodeDatum }) {
|
||||
}
|
||||
|
||||
const nonZero = node.arcSections.filter((s) => s.value !== 0);
|
||||
if (nonZero.length === 0) {
|
||||
// Fallback if no arc is defined
|
||||
return <circle fill="none" stroke={node.color} strokeWidth={2} r={nodeR} cx={node.x} cy={node.y} />;
|
||||
}
|
||||
|
||||
const { elements } = nonZero.reduce(
|
||||
(acc, section) => {
|
||||
|
@ -87,11 +87,14 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
||||
const firstNodesDataFrame = nodesDataFrames[0];
|
||||
const firstEdgesDataFrame = edgesDataFrames[0];
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in
|
||||
// that case should be unique or figure a way to link edges and nodes dataframes together.
|
||||
const processed = useMemo(() => processNodes(firstNodesDataFrame, firstEdgesDataFrame), [
|
||||
const processed = useMemo(() => processNodes(firstNodesDataFrame, firstEdgesDataFrame, theme), [
|
||||
firstEdgesDataFrame,
|
||||
firstNodesDataFrame,
|
||||
theme,
|
||||
]);
|
||||
|
||||
const { nodes: rawNodes, edges: rawEdges } = useNodeLimit(processed.nodes, processed.edges, nodeCountLimit);
|
||||
@ -102,16 +105,19 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
||||
bounds
|
||||
);
|
||||
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(getLinks, nodesDataFrames[0], edgesDataFrames[0]);
|
||||
const styles = getStyles(useTheme());
|
||||
const styles = getStyles(theme);
|
||||
|
||||
// This cannot be inline func or it will create infinite render cycle.
|
||||
const topLevelRef = useCallback(
|
||||
(r) => {
|
||||
measureRef(r);
|
||||
(zoomRef as MutableRefObject<HTMLElement | null>).current = r;
|
||||
},
|
||||
[measureRef, zoomRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(r) => {
|
||||
measureRef(r);
|
||||
(zoomRef as MutableRefObject<HTMLElement | null>).current = r;
|
||||
}}
|
||||
className={styles.wrapper}
|
||||
>
|
||||
<div ref={topLevelRef} className={styles.wrapper}>
|
||||
<svg
|
||||
ref={panRef}
|
||||
viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
|
||||
@ -233,7 +239,8 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
||||
(e.source as NodeDatum).id === props.nodeHoveringId ||
|
||||
(e.target as NodeDatum).id === props.nodeHoveringId ||
|
||||
props.edgeHoveringId === e.id;
|
||||
return shouldShow && <EdgeLabel key={e.id} edge={e} />;
|
||||
const hasStats = e.mainStat || e.secondaryStat;
|
||||
return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ export type NodeDatum = SimulationNodeDatum & {
|
||||
value: number;
|
||||
color: string;
|
||||
}>;
|
||||
color: string;
|
||||
};
|
||||
export type EdgeDatum = SimulationLinkDatum<NodeDatum> & {
|
||||
id: string;
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils';
|
||||
import lightTheme from '../../themes/light';
|
||||
|
||||
describe('processNodes', () => {
|
||||
it('handles empty args', async () => {
|
||||
expect(processNodes()).toEqual({ nodes: [], edges: [] });
|
||||
expect(processNodes(undefined, undefined, lightTheme)).toEqual({ nodes: [], edges: [] });
|
||||
});
|
||||
|
||||
it('returns proper nodes and edges', async () => {
|
||||
@ -13,7 +14,8 @@ describe('processNodes', () => {
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
])
|
||||
]),
|
||||
lightTheme
|
||||
)
|
||||
).toEqual({
|
||||
nodes: [
|
||||
@ -28,6 +30,7 @@ describe('processNodes', () => {
|
||||
value: 0.5,
|
||||
},
|
||||
],
|
||||
color: 'rgb(213, 172, 32)',
|
||||
dataFrameRowIndex: 0,
|
||||
id: '0',
|
||||
incoming: 0,
|
||||
@ -47,6 +50,7 @@ describe('processNodes', () => {
|
||||
value: 0.5,
|
||||
},
|
||||
],
|
||||
color: 'rgb(213, 172, 32)',
|
||||
dataFrameRowIndex: 1,
|
||||
id: '1',
|
||||
incoming: 1,
|
||||
@ -66,6 +70,7 @@ describe('processNodes', () => {
|
||||
value: 0.5,
|
||||
},
|
||||
],
|
||||
color: 'rgb(213, 172, 32)',
|
||||
dataFrameRowIndex: 2,
|
||||
id: '2',
|
||||
incoming: 2,
|
||||
|
@ -1,4 +1,13 @@
|
||||
import { DataFrame, Field, FieldCache, FieldType, ArrayVector, MutableDataFrame } from '@grafana/data';
|
||||
import {
|
||||
ArrayVector,
|
||||
DataFrame,
|
||||
Field,
|
||||
FieldCache,
|
||||
FieldType,
|
||||
getFieldColorModeForField,
|
||||
GrafanaTheme,
|
||||
MutableDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { EdgeDatum, NodeDatum } from './types';
|
||||
import { NodeGraphDataFrameFieldNames } from './index';
|
||||
|
||||
@ -36,6 +45,7 @@ export function getNodeFields(nodes: DataFrame) {
|
||||
secondaryStat: fieldsCache.getFieldByName(DataFrameFieldNames.secondaryStat),
|
||||
arc: findFieldsByPrefix(nodes, DataFrameFieldNames.arc),
|
||||
details: findFieldsByPrefix(nodes, DataFrameFieldNames.detail),
|
||||
color: fieldsCache.getFieldByName(DataFrameFieldNames.color),
|
||||
};
|
||||
}
|
||||
|
||||
@ -65,12 +75,17 @@ export enum DataFrameFieldNames {
|
||||
target = 'target',
|
||||
detail = 'detail__',
|
||||
arc = 'arc__',
|
||||
color = 'color',
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform nodes and edges dataframes into array of objects that the layout code can then work with.
|
||||
*/
|
||||
export function processNodes(nodes?: DataFrame, edges?: DataFrame): { nodes: NodeDatum[]; edges: EdgeDatum[] } {
|
||||
export function processNodes(
|
||||
nodes: DataFrame | undefined,
|
||||
edges: DataFrame | undefined,
|
||||
theme: GrafanaTheme
|
||||
): { nodes: NodeDatum[]; edges: EdgeDatum[] } {
|
||||
if (!nodes) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
@ -96,6 +111,7 @@ export function processNodes(nodes?: DataFrame, edges?: DataFrame): { nodes: Nod
|
||||
color: f.config.color?.fixedColor || '',
|
||||
};
|
||||
}),
|
||||
color: nodeFields.color ? getColor(nodeFields.color, index, theme) : '',
|
||||
};
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
@ -167,6 +183,7 @@ function makeNode(index: number) {
|
||||
arc__errors: 0.5,
|
||||
mainStat: 0.1,
|
||||
secondaryStat: 2,
|
||||
color: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
@ -202,6 +219,12 @@ function nodesFrame() {
|
||||
type: FieldType.number,
|
||||
config: { color: { fixedColor: 'red' } },
|
||||
},
|
||||
|
||||
[NodeGraphDataFrameFieldNames.color]: {
|
||||
values: new ArrayVector(),
|
||||
type: FieldType.number,
|
||||
config: { color: { mode: 'continuous-GrYlRd' } },
|
||||
},
|
||||
};
|
||||
|
||||
return new MutableDataFrame({
|
||||
@ -252,3 +275,11 @@ function edgesFrame() {
|
||||
meta: { preferredVisualisationType: 'nodeGraph' },
|
||||
});
|
||||
}
|
||||
|
||||
function getColor(field: Field, index: number, theme: GrafanaTheme): string {
|
||||
if (!field.config.color) {
|
||||
return field.values.get(index);
|
||||
}
|
||||
|
||||
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
|
||||
}
|
||||
|
@ -1,16 +1,14 @@
|
||||
import {
|
||||
ArrayVector,
|
||||
DataQueryRequest,
|
||||
DataSourceInstanceSettings,
|
||||
dateTime,
|
||||
FieldType,
|
||||
PluginType,
|
||||
} from '@grafana/data';
|
||||
import { DataQueryRequest, DataSourceInstanceSettings, dateTime, FieldType, PluginType } from '@grafana/data';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { createFetchResponse } from 'test/helpers/createFetchResponse';
|
||||
import { JaegerDatasource, JaegerQuery } from './datasource';
|
||||
import { testResponse } from './testResponse';
|
||||
import {
|
||||
testResponse,
|
||||
testResponseDataFrameFields,
|
||||
testResponseNodesFields,
|
||||
testResponseEdgesFields,
|
||||
} from './testResponse';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...((jest.requireActual('@grafana/runtime') as unknown) as object),
|
||||
@ -22,51 +20,15 @@ describe('JaegerDatasource', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns trace when queried', async () => {
|
||||
it('returns trace and graph when queried', async () => {
|
||||
setupFetchMock({ data: [testResponse] });
|
||||
|
||||
const ds = new JaegerDatasource(defaultSettings);
|
||||
await expect(ds.query(defaultQuery)).toEmitValuesWith((val) => {
|
||||
expect(val[0].data[0].fields).toMatchObject(
|
||||
[
|
||||
{ name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] },
|
||||
{ name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] },
|
||||
{ name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] },
|
||||
{ name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] },
|
||||
{ name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] },
|
||||
{
|
||||
name: 'serviceTags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'startTime', values: [1605873894680.409, 1605873894680.587] },
|
||||
{ name: 'duration', values: [1049.141, 1.847] },
|
||||
{ name: 'logs', values: [[], []] },
|
||||
{
|
||||
name: 'tags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'sampler.type', type: 'string', value: 'probabilistic' },
|
||||
{ key: 'sampler.param', type: 'float64', value: 1 },
|
||||
],
|
||||
[
|
||||
{ key: 'component', type: 'string', value: 'gRPC' },
|
||||
{ key: 'span.kind', type: 'string', value: 'client' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'warnings', values: [undefined, undefined] },
|
||||
{ name: 'stackTraces', values: [undefined, undefined] },
|
||||
].map((f) => ({ ...f, values: new ArrayVector<any>(f.values) }))
|
||||
);
|
||||
});
|
||||
const response = await ds.query(defaultQuery).toPromise();
|
||||
expect(response.data.length).toBe(3);
|
||||
expect(response.data[0].fields).toMatchObject(testResponseDataFrameFields);
|
||||
expect(response.data[1].fields).toMatchObject(testResponseNodesFields);
|
||||
expect(response.data[2].fields).toMatchObject(testResponseEdgesFields);
|
||||
});
|
||||
|
||||
it('returns trace when traceId with special characters is queried', async () => {
|
||||
|
@ -15,6 +15,7 @@ import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { createTraceFrame } from './responseTransform';
|
||||
import { createGraphFrames } from './graphTransform';
|
||||
|
||||
export type JaegerQuery = {
|
||||
query: string;
|
||||
@ -41,8 +42,13 @@ export class JaegerDatasource extends DataSourceApi<JaegerQuery> {
|
||||
// TODO: this api is internal, used in jaeger ui. Officially they have gRPC api that should be used.
|
||||
return this._request(`/api/traces/${encodeURIComponent(id)}`).pipe(
|
||||
map((response) => {
|
||||
// We assume there is only one trace, as the querying right now does not work to query for multiple traces.
|
||||
const traceData = response?.data?.data?.[0];
|
||||
if (!traceData) {
|
||||
return { data: [emptyTraceDataFrame] };
|
||||
}
|
||||
return {
|
||||
data: [createTraceFrame(response?.data?.data?.[0] || [])],
|
||||
data: [createTraceFrame(traceData), ...createGraphFrames(traceData)],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
66
public/app/plugins/datasource/jaeger/graphTransform.test.ts
Normal file
66
public/app/plugins/datasource/jaeger/graphTransform.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { createGraphFrames } from './graphTransform';
|
||||
import {
|
||||
testResponse,
|
||||
testResponseEdgesFields,
|
||||
testResponseNodesFields,
|
||||
toEdgesFrame,
|
||||
toNodesFrame,
|
||||
} from './testResponse';
|
||||
import { TraceResponse } from './types';
|
||||
|
||||
describe('createGraphFrames', () => {
|
||||
it('transforms basic response into nodes and edges frame', async () => {
|
||||
const frames = createGraphFrames(testResponse);
|
||||
expect(frames.length).toBe(2);
|
||||
expect(frames[0].fields).toMatchObject(testResponseNodesFields);
|
||||
expect(frames[1].fields).toMatchObject(testResponseEdgesFields);
|
||||
});
|
||||
|
||||
it('handles single span response', async () => {
|
||||
const frames = createGraphFrames(singleSpanResponse);
|
||||
expect(frames.length).toBe(2);
|
||||
expect(frames[0].fields).toMatchObject(
|
||||
toNodesFrame([
|
||||
['3fa414edcef6ad90'],
|
||||
['tempo-querier'],
|
||||
['HTTP GET - api_traces_traceid'],
|
||||
['total: 1049.14ms (100%)'],
|
||||
['self: 1049.14ms (100%)'],
|
||||
[1],
|
||||
])
|
||||
);
|
||||
expect(frames[1].fields).toMatchObject(toEdgesFrame([[], [], []]));
|
||||
});
|
||||
});
|
||||
|
||||
export const singleSpanResponse: TraceResponse = {
|
||||
traceID: '3fa414edcef6ad90',
|
||||
spans: [
|
||||
{
|
||||
traceID: '3fa414edcef6ad90',
|
||||
spanID: '3fa414edcef6ad90',
|
||||
operationName: 'HTTP GET - api_traces_traceid',
|
||||
references: [],
|
||||
startTime: 1605873894680409,
|
||||
duration: 1049141,
|
||||
tags: [
|
||||
{ key: 'sampler.type', type: 'string', value: 'probabilistic' },
|
||||
{ key: 'sampler.param', type: 'float64', value: 1 },
|
||||
],
|
||||
logs: [],
|
||||
processID: 'p1',
|
||||
warnings: null,
|
||||
flags: 0,
|
||||
},
|
||||
],
|
||||
processes: {
|
||||
p1: {
|
||||
serviceName: 'tempo-querier',
|
||||
tags: [
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: null,
|
||||
};
|
181
public/app/plugins/datasource/jaeger/graphTransform.ts
Normal file
181
public/app/plugins/datasource/jaeger/graphTransform.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
|
||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui';
|
||||
import { Span, TraceResponse } from './types';
|
||||
|
||||
interface Node {
|
||||
[Fields.id]: string;
|
||||
[Fields.title]: string;
|
||||
[Fields.subTitle]: string;
|
||||
[Fields.mainStat]: string;
|
||||
[Fields.secondaryStat]: string;
|
||||
[Fields.color]: number;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
[Fields.id]: string;
|
||||
[Fields.target]: string;
|
||||
[Fields.source]: string;
|
||||
}
|
||||
|
||||
export function createGraphFrames(data: TraceResponse): DataFrame[] {
|
||||
const { nodes, edges } = convertTraceToGraph(data);
|
||||
|
||||
const nodesFrame = new MutableDataFrame({
|
||||
fields: [
|
||||
{ name: Fields.id, type: FieldType.string },
|
||||
{ name: Fields.title, type: FieldType.string },
|
||||
{ name: Fields.subTitle, type: FieldType.string },
|
||||
{ name: Fields.mainStat, type: FieldType.string },
|
||||
{ name: Fields.secondaryStat, type: FieldType.string },
|
||||
{ name: Fields.color, type: FieldType.number, config: { color: { mode: 'continuous-GrYlRd' } } },
|
||||
],
|
||||
meta: {
|
||||
preferredVisualisationType: 'nodeGraph',
|
||||
},
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
nodesFrame.add(node);
|
||||
}
|
||||
|
||||
const edgesFrame = new MutableDataFrame({
|
||||
fields: [
|
||||
{ name: Fields.id, type: FieldType.string },
|
||||
{ name: Fields.target, type: FieldType.string },
|
||||
{ name: Fields.source, type: FieldType.string },
|
||||
],
|
||||
meta: {
|
||||
preferredVisualisationType: 'nodeGraph',
|
||||
},
|
||||
});
|
||||
|
||||
for (const edge of edges) {
|
||||
edgesFrame.add(edge);
|
||||
}
|
||||
|
||||
return [nodesFrame, edgesFrame];
|
||||
}
|
||||
|
||||
function convertTraceToGraph(data: TraceResponse): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
const traceDuration = findTraceDuration(data.spans);
|
||||
const spanMap = makeSpanMap(data.spans);
|
||||
|
||||
for (const span of data.spans) {
|
||||
const process = data.processes[span.processID];
|
||||
const childrenDuration = getDuration(spanMap[span.spanID].children.map((c) => spanMap[c].span));
|
||||
const selfDuration = span.duration - childrenDuration;
|
||||
|
||||
nodes.push({
|
||||
[Fields.id]: span.spanID,
|
||||
[Fields.title]: process?.serviceName ?? '',
|
||||
[Fields.subTitle]: span.operationName,
|
||||
[Fields.mainStat]: `total: ${toFixedNoTrailingZeros(span.duration / 1000)}ms (${toFixedNoTrailingZeros(
|
||||
(span.duration / traceDuration) * 100
|
||||
)}%)`,
|
||||
[Fields.secondaryStat]: `self: ${toFixedNoTrailingZeros(selfDuration / 1000)}ms (${toFixedNoTrailingZeros(
|
||||
(selfDuration / span.duration) * 100
|
||||
)}%)`,
|
||||
[Fields.color]: selfDuration / traceDuration,
|
||||
});
|
||||
|
||||
const parentSpanID = span.references?.find((r) => r.refType === 'CHILD_OF')?.spanID;
|
||||
if (parentSpanID) {
|
||||
edges.push({
|
||||
[Fields.id]: parentSpanID + '--' + span.spanID,
|
||||
[Fields.target]: span.spanID,
|
||||
[Fields.source]: parentSpanID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function toFixedNoTrailingZeros(n: number) {
|
||||
return parseFloat(n.toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the duration of the whole trace as it isn't a part of the response data.
|
||||
* Note: Seems like this should be the same as just longest span, but this is probably safer.
|
||||
*/
|
||||
function findTraceDuration(spans: Span[]): number {
|
||||
let traceEndTime = 0;
|
||||
let traceStartTime = Infinity;
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.startTime < traceStartTime) {
|
||||
traceStartTime = span.startTime;
|
||||
}
|
||||
|
||||
if (span.startTime + span.duration > traceEndTime) {
|
||||
traceEndTime = span.startTime + span.duration;
|
||||
}
|
||||
}
|
||||
|
||||
return traceEndTime - traceStartTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of the spans with children array for easier processing.
|
||||
*/
|
||||
function makeSpanMap(spans: Span[]): { [id: string]: { span: Span; children: string[] } } {
|
||||
const spanMap: { [id: string]: { span?: Span; children: string[] } } = {};
|
||||
|
||||
for (const span of spans) {
|
||||
if (!spanMap[span.spanID]) {
|
||||
spanMap[span.spanID] = {
|
||||
span,
|
||||
children: [],
|
||||
};
|
||||
} else {
|
||||
spanMap[span.spanID].span = span;
|
||||
}
|
||||
for (const parent of span.references?.filter((r) => r.refType === 'CHILD_OF').map((r) => r.spanID) || []) {
|
||||
if (!spanMap[parent]) {
|
||||
spanMap[parent] = {
|
||||
span: undefined,
|
||||
children: [span.spanID],
|
||||
};
|
||||
} else {
|
||||
spanMap[parent].children.push(span.spanID);
|
||||
}
|
||||
}
|
||||
}
|
||||
return spanMap as { [id: string]: { span: Span; children: string[] } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get non overlapping duration of the spans.
|
||||
*/
|
||||
function getDuration(spans: Span[]): number {
|
||||
const ranges = spans.map<[number, number]>((span) => [span.startTime, span.startTime + span.duration]);
|
||||
ranges.sort((a, b) => a[0] - b[0]);
|
||||
const mergedRanges = ranges.reduce((acc, range) => {
|
||||
if (!acc.length) {
|
||||
return [range];
|
||||
}
|
||||
const tail = acc.slice(-1)[0];
|
||||
const [prevStart, prevEnd] = tail;
|
||||
const [start, end] = range;
|
||||
if (end < prevEnd) {
|
||||
// In this case the range is completely inside the prev range so we can just ignore it.
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (start > prevEnd) {
|
||||
// There is no overlap so we can just add it to stack
|
||||
return [...acc, range];
|
||||
}
|
||||
|
||||
// We know there is overlap and current range ends later than previous so we can just extend the range
|
||||
return [...acc.slice(0, -1), [prevStart, end]] as Array<[number, number]>;
|
||||
}, [] as Array<[number, number]>);
|
||||
|
||||
return mergedRanges.reduce((acc, range) => {
|
||||
return acc + (range[1] - range[0]);
|
||||
}, 0);
|
||||
}
|
@ -1,49 +1,9 @@
|
||||
import { createTraceFrame } from './responseTransform';
|
||||
import { ArrayVector } from '@grafana/data';
|
||||
import { testResponse } from './testResponse';
|
||||
import { testResponse, testResponseDataFrameFields } from './testResponse';
|
||||
|
||||
describe('createTraceFrame', () => {
|
||||
it('creates data frame from jaeger response', () => {
|
||||
const dataFrame = createTraceFrame(testResponse);
|
||||
expect(dataFrame.fields).toMatchObject(
|
||||
[
|
||||
{ name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] },
|
||||
{ name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] },
|
||||
{ name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] },
|
||||
{ name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] },
|
||||
{ name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] },
|
||||
{
|
||||
name: 'serviceTags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'startTime', values: [1605873894680.409, 1605873894680.587] },
|
||||
{ name: 'duration', values: [1049.141, 1.847] },
|
||||
{ name: 'logs', values: [[], []] },
|
||||
{
|
||||
name: 'tags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'sampler.type', type: 'string', value: 'probabilistic' },
|
||||
{ key: 'sampler.param', type: 'float64', value: 1 },
|
||||
],
|
||||
[
|
||||
{ key: 'component', type: 'string', value: 'gRPC' },
|
||||
{ key: 'span.kind', type: 'string', value: 'client' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'warnings', values: [undefined, undefined] },
|
||||
{ name: 'stackTraces', values: [undefined, undefined] },
|
||||
].map((f) => ({ ...f, values: new ArrayVector<any>(f.values) }))
|
||||
);
|
||||
expect(dataFrame.fields).toMatchObject(testResponseDataFrameFields);
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TraceResponse } from './types';
|
||||
import { ArrayVector, FieldDTO } from '@grafana/data';
|
||||
|
||||
export const testResponse: TraceResponse = {
|
||||
traceID: '3fa414edcef6ad90',
|
||||
@ -47,3 +48,88 @@ export const testResponse: TraceResponse = {
|
||||
},
|
||||
warnings: null,
|
||||
};
|
||||
|
||||
function toVectors(fields: FieldDTO[]) {
|
||||
return fields.map((f) => ({ ...f, values: new ArrayVector<any>(f.values as any[]) }));
|
||||
}
|
||||
|
||||
export const testResponseDataFrameFields = toVectors([
|
||||
{ name: 'traceID', values: ['3fa414edcef6ad90', '3fa414edcef6ad90'] },
|
||||
{ name: 'spanID', values: ['3fa414edcef6ad90', '0f5c1808567e4403'] },
|
||||
{ name: 'parentSpanID', values: [undefined, '3fa414edcef6ad90'] },
|
||||
{ name: 'operationName', values: ['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'] },
|
||||
{ name: 'serviceName', values: ['tempo-querier', 'tempo-querier'] },
|
||||
{
|
||||
name: 'serviceTags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
[
|
||||
{ key: 'cluster', type: 'string', value: 'ops-tools1' },
|
||||
{ key: 'container', type: 'string', value: 'tempo-query' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'startTime', values: [1605873894680.409, 1605873894680.587] },
|
||||
{ name: 'duration', values: [1049.141, 1.847] },
|
||||
{ name: 'logs', values: [[], []] },
|
||||
{
|
||||
name: 'tags',
|
||||
values: [
|
||||
[
|
||||
{ key: 'sampler.type', type: 'string', value: 'probabilistic' },
|
||||
{ key: 'sampler.param', type: 'float64', value: 1 },
|
||||
],
|
||||
[
|
||||
{ key: 'component', type: 'string', value: 'gRPC' },
|
||||
{ key: 'span.kind', type: 'string', value: 'client' },
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: 'warnings', values: [undefined, undefined] },
|
||||
{ name: 'stackTraces', values: [undefined, undefined] },
|
||||
]);
|
||||
|
||||
export const testResponseNodesFields = toNodesFrame([
|
||||
['3fa414edcef6ad90', '0f5c1808567e4403'],
|
||||
['tempo-querier', 'tempo-querier'],
|
||||
['HTTP GET - api_traces_traceid', '/tempopb.Querier/FindTraceByID'],
|
||||
['total: 1049.14ms (100%)', 'total: 1.85ms (0.18%)'],
|
||||
['self: 1047.29ms (99.82%)', 'self: 1.85ms (100%)'],
|
||||
[0.9982395121342127, 0.0017604878657873442],
|
||||
]);
|
||||
|
||||
export const testResponseEdgesFields = toEdgesFrame([
|
||||
['3fa414edcef6ad90--0f5c1808567e4403'],
|
||||
['0f5c1808567e4403'],
|
||||
['3fa414edcef6ad90'],
|
||||
]);
|
||||
|
||||
export function toNodesFrame(values: any[]) {
|
||||
return toVectors([
|
||||
{ name: 'id', values: values[0] },
|
||||
{ name: 'title', values: values[1] },
|
||||
{ name: 'subTitle', values: values[2] },
|
||||
{ name: 'mainStat', values: values[3] },
|
||||
{ name: 'secondaryStat', values: values[4] },
|
||||
{
|
||||
name: 'color',
|
||||
config: {
|
||||
color: {
|
||||
mode: 'continuous-GrYlRd',
|
||||
},
|
||||
},
|
||||
values: values[5],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export function toEdgesFrame(values: any[]) {
|
||||
return toVectors([
|
||||
{ name: 'id', values: values[0] },
|
||||
{ name: 'target', values: values[1] },
|
||||
{ name: 'source', values: values[2] },
|
||||
]);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user