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