mirror of https://github.com/grafana/grafana
Zipkin: Add node graph view to trace response (#34414)
* Add graph transform * Add tests * Refactor code * Update test * Fix zipkin block Co-authored-by: David Kaltschmidt <david.kaltschmidt@gmail.com>pull/34439/head
parent
d1d118a474
commit
615de9bf34
@ -0,0 +1,118 @@ |
|||||||
|
/** |
||||||
|
* Get non overlapping duration of the ranges as they can overlap or have gaps. |
||||||
|
*/ |
||||||
|
import { FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; |
||||||
|
|
||||||
|
export function getNonOverlappingDuration(ranges: Array<[number, number]>): number { |
||||||
|
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); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a map of the spans with children array for easier processing. It will also contain empty spans in case |
||||||
|
* span is missing but other spans are it's children. This is more generic because it needs to allow iterating over |
||||||
|
* both arrays and dataframe views. |
||||||
|
*/ |
||||||
|
export function makeSpanMap<T>( |
||||||
|
getSpan: (index: number) => { span: T; id: string; parentIds: string[] } | undefined |
||||||
|
): { [id: string]: { span: T; children: string[] } } { |
||||||
|
const spanMap: { [id: string]: { span?: T; children: string[] } } = {}; |
||||||
|
|
||||||
|
let span; |
||||||
|
for (let index = 0; (span = getSpan(index)), !!span; index++) { |
||||||
|
if (!spanMap[span.id]) { |
||||||
|
spanMap[span.id] = { |
||||||
|
span: span.span, |
||||||
|
children: [], |
||||||
|
}; |
||||||
|
} else { |
||||||
|
spanMap[span.id].span = span.span; |
||||||
|
} |
||||||
|
|
||||||
|
for (const parentId of span.parentIds) { |
||||||
|
if (parentId) { |
||||||
|
if (!spanMap[parentId]) { |
||||||
|
spanMap[parentId] = { |
||||||
|
span: undefined, |
||||||
|
children: [span.id], |
||||||
|
}; |
||||||
|
} else { |
||||||
|
spanMap[parentId].children.push(span.id); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return spanMap as { [id: string]: { span: T; children: string[] } }; |
||||||
|
} |
||||||
|
|
||||||
|
export function getStats(duration: number, traceDuration: number, selfDuration: number) { |
||||||
|
return { |
||||||
|
main: `${toFixedNoTrailingZeros(duration)}ms (${toFixedNoTrailingZeros((duration / traceDuration) * 100)}%)`, |
||||||
|
secondary: `${toFixedNoTrailingZeros(selfDuration)}ms (${toFixedNoTrailingZeros( |
||||||
|
(selfDuration / duration) * 100 |
||||||
|
)}%)`,
|
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function toFixedNoTrailingZeros(n: number) { |
||||||
|
return parseFloat(n.toFixed(2)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create default frames used when returning data for node graph. |
||||||
|
*/ |
||||||
|
export function makeFrames() { |
||||||
|
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, config: { displayName: 'Total time (% of trace)' } }, |
||||||
|
{ name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } }, |
||||||
|
{ |
||||||
|
name: Fields.color, |
||||||
|
type: FieldType.number, |
||||||
|
config: { color: { mode: 'continuous-GrYlRd' }, displayName: 'Self time / Trace duration' }, |
||||||
|
}, |
||||||
|
], |
||||||
|
meta: { |
||||||
|
preferredVisualisationType: 'nodeGraph', |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
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', |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
return [nodesFrame, edgesFrame]; |
||||||
|
} |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
import { createGraphFrames } from './graphTransform'; |
||||||
|
import { |
||||||
|
testResponse, |
||||||
|
testResponseEdgesFields, |
||||||
|
testResponseNodesFields, |
||||||
|
toEdgesFrame, |
||||||
|
toNodesFrame, |
||||||
|
} from './testResponse'; |
||||||
|
import { ZipkinSpan } 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'], |
||||||
|
['1049.14ms (100%)'], |
||||||
|
['1049.14ms (100%)'], |
||||||
|
[1], |
||||||
|
]) |
||||||
|
); |
||||||
|
expect(frames[1].fields).toMatchObject(toEdgesFrame([[], [], []])); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles missing spans', async () => { |
||||||
|
const frames = createGraphFrames(missingSpanResponse); |
||||||
|
expect(frames.length).toBe(2); |
||||||
|
expect(frames[0].length).toBe(2); |
||||||
|
expect(frames[1].length).toBe(0); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
export const singleSpanResponse: ZipkinSpan[] = [ |
||||||
|
{ |
||||||
|
traceId: '3fa414edcef6ad90', |
||||||
|
id: '3fa414edcef6ad90', |
||||||
|
name: 'HTTP GET - api_traces_traceid', |
||||||
|
timestamp: 1605873894680409, |
||||||
|
duration: 1049141, |
||||||
|
tags: { |
||||||
|
component: 'gRPC', |
||||||
|
spanKind: 'client', |
||||||
|
}, |
||||||
|
localEndpoint: { |
||||||
|
serviceName: 'tempo-querier', |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export const missingSpanResponse: ZipkinSpan[] = [ |
||||||
|
{ |
||||||
|
traceId: '3fa414edcef6ad90', |
||||||
|
id: '1', |
||||||
|
name: 'HTTP GET - api_traces_traceid', |
||||||
|
timestamp: 1605873894680409, |
||||||
|
duration: 1049141, |
||||||
|
}, |
||||||
|
{ |
||||||
|
traceId: '3fa414edcef6ad90', |
||||||
|
id: '2', |
||||||
|
name: 'HTTP GET - api_traces_traceid', |
||||||
|
parentId: '3', |
||||||
|
timestamp: 1605873894680409, |
||||||
|
duration: 1049141, |
||||||
|
}, |
||||||
|
]; |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
import { DataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; |
||||||
|
import { ZipkinSpan } from '../types'; |
||||||
|
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../../core/utils/tracing'; |
||||||
|
|
||||||
|
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: ZipkinSpan[]): DataFrame[] { |
||||||
|
const { nodes, edges } = convertTraceToGraph(data); |
||||||
|
const [nodesFrame, edgesFrame] = makeFrames(); |
||||||
|
|
||||||
|
for (const node of nodes) { |
||||||
|
nodesFrame.add(node); |
||||||
|
} |
||||||
|
|
||||||
|
for (const edge of edges) { |
||||||
|
edgesFrame.add(edge); |
||||||
|
} |
||||||
|
|
||||||
|
return [nodesFrame, edgesFrame]; |
||||||
|
} |
||||||
|
|
||||||
|
function convertTraceToGraph(spans: ZipkinSpan[]): { nodes: Node[]; edges: Edge[] } { |
||||||
|
const nodes: Node[] = []; |
||||||
|
const edges: Edge[] = []; |
||||||
|
|
||||||
|
const traceDuration = findTraceDuration(spans); |
||||||
|
const spanMap = makeSpanMap((index) => { |
||||||
|
if (index >= spans.length) { |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
return { |
||||||
|
span: spans[index], |
||||||
|
id: spans[index].id, |
||||||
|
parentIds: spans[index].parentId ? [spans[index].parentId!] : [], |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
for (const span of spans) { |
||||||
|
const ranges: Array<[number, number]> = spanMap[span.id].children.map((c) => { |
||||||
|
const span = spanMap[c].span; |
||||||
|
return [span.timestamp, span.timestamp + span.duration]; |
||||||
|
}); |
||||||
|
const childrenDuration = getNonOverlappingDuration(ranges); |
||||||
|
const selfDuration = span.duration - childrenDuration; |
||||||
|
const stats = getStats(span.duration / 1000, traceDuration / 1000, selfDuration / 1000); |
||||||
|
|
||||||
|
nodes.push({ |
||||||
|
[Fields.id]: span.id, |
||||||
|
[Fields.title]: span.localEndpoint?.serviceName || span.remoteEndpoint?.serviceName || 'unknown', |
||||||
|
[Fields.subTitle]: span.name, |
||||||
|
[Fields.mainStat]: stats.main, |
||||||
|
[Fields.secondaryStat]: stats.secondary, |
||||||
|
[Fields.color]: selfDuration / traceDuration, |
||||||
|
}); |
||||||
|
|
||||||
|
if (span.parentId && spanMap[span.parentId].span) { |
||||||
|
edges.push({ |
||||||
|
[Fields.id]: span.parentId + '--' + span.id, |
||||||
|
[Fields.target]: span.id, |
||||||
|
[Fields.source]: span.parentId, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { nodes, edges }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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: ZipkinSpan[]): number { |
||||||
|
let traceEndTime = 0; |
||||||
|
let traceStartTime = Infinity; |
||||||
|
|
||||||
|
for (const span of spans) { |
||||||
|
if (span.timestamp < traceStartTime) { |
||||||
|
traceStartTime = span.timestamp; |
||||||
|
} |
||||||
|
|
||||||
|
if (span.timestamp + span.duration > traceEndTime) { |
||||||
|
traceEndTime = span.timestamp + span.duration; |
||||||
|
} |
||||||
|
} |
||||||
|
return traceEndTime - traceStartTime; |
||||||
|
} |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
import { ArrayVector, FieldDTO } from '@grafana/data'; |
||||||
|
import { ZipkinSpan } from '../types'; |
||||||
|
|
||||||
|
export const testResponse: ZipkinSpan[] = [ |
||||||
|
{ |
||||||
|
traceId: '3fa414edcef6ad90', |
||||||
|
id: '3fa414edcef6ad90', |
||||||
|
name: 'HTTP GET - api_traces_traceid', |
||||||
|
timestamp: 1605873894680409, |
||||||
|
duration: 1049141, |
||||||
|
tags: { |
||||||
|
samplerType: 'probabilistic', |
||||||
|
samplerParam: '1', |
||||||
|
}, |
||||||
|
localEndpoint: { |
||||||
|
serviceName: 'tempo-querier', |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
traceId: '3fa414edcef6ad90', |
||||||
|
id: '0f5c1808567e4403', |
||||||
|
name: '/tempopb.Querier/FindTraceByID', |
||||||
|
parentId: '3fa414edcef6ad90', |
||||||
|
timestamp: 1605873894680587, |
||||||
|
duration: 1847, |
||||||
|
tags: { |
||||||
|
component: 'gRPC', |
||||||
|
spanKind: 'client', |
||||||
|
}, |
||||||
|
localEndpoint: { |
||||||
|
serviceName: 'tempo-querier', |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
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'], |
||||||
|
['1049.14ms (100%)', '1.85ms (0.18%)'], |
||||||
|
['1047.29ms (99.82%)', '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