mirror of https://github.com/grafana/grafana
Tempo: Show graph view of the trace (#33635)
* Add transform * Add test for transform * Add test * Update testpull/33722/head
parent
1e3d19e483
commit
24e52004a1
@ -0,0 +1,64 @@ |
||||
import { createGraphFrames } from './graphTransform'; |
||||
import { bigResponse } from './testResponse'; |
||||
import { DataFrameView, MutableDataFrame } from '@grafana/data'; |
||||
|
||||
describe('createGraphFrames', () => { |
||||
it('transforms basic response into nodes and edges frame', async () => { |
||||
const frames = createGraphFrames(bigResponse); |
||||
expect(frames.length).toBe(2); |
||||
expect(frames[0].length).toBe(30); |
||||
expect(frames[1].length).toBe(29); |
||||
|
||||
let view = new DataFrameView(frames[0]); |
||||
expect(view.get(0)).toMatchObject({ |
||||
id: '4322526419282105830', |
||||
title: 'loki-all', |
||||
subTitle: 'store.validateQueryTimeRange', |
||||
mainStat: 'total: 0ms (0.02%)', |
||||
secondaryStat: 'self: 0ms (100%)', |
||||
color: 0.00021968356127648162, |
||||
}); |
||||
|
||||
expect(view.get(29)).toMatchObject({ |
||||
id: '4450900759028499335', |
||||
title: 'loki-all', |
||||
subTitle: 'HTTP GET - loki_api_v1_query_range', |
||||
mainStat: 'total: 18.21ms (100%)', |
||||
secondaryStat: 'self: 3.22ms (17.71%)', |
||||
color: 0.17707117189595056, |
||||
}); |
||||
|
||||
view = new DataFrameView(frames[1]); |
||||
expect(view.get(28)).toMatchObject({ |
||||
id: '4450900759028499335--4790760741274015949', |
||||
}); |
||||
}); |
||||
|
||||
it('handles single span response', async () => { |
||||
const frames = createGraphFrames(singleSpanResponse); |
||||
expect(frames.length).toBe(2); |
||||
expect(frames[0].length).toBe(1); |
||||
|
||||
const view = new DataFrameView(frames[0]); |
||||
expect(view.get(0)).toMatchObject({ |
||||
id: '4322526419282105830', |
||||
title: 'loki-all', |
||||
subTitle: 'store.validateQueryTimeRange', |
||||
mainStat: 'total: 14.98ms (100%)', |
||||
secondaryStat: 'self: 14.98ms (100%)', |
||||
color: 1.000007560204647, |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
const singleSpanResponse = new MutableDataFrame({ |
||||
fields: [ |
||||
{ name: 'traceID', values: ['04450900759028499335'] }, |
||||
{ name: 'spanID', values: ['4322526419282105830'] }, |
||||
{ name: 'parentSpanID', values: [''] }, |
||||
{ name: 'operationName', values: ['store.validateQueryTimeRange'] }, |
||||
{ name: 'serviceName', values: ['loki-all'] }, |
||||
{ name: 'startTime', values: [1619712655875.4539] }, |
||||
{ name: 'duration', values: [14.984] }, |
||||
], |
||||
}); |
@ -0,0 +1,198 @@ |
||||
import { DataFrame, DataFrameView, FieldType, MutableDataFrame } from '@grafana/data'; |
||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui'; |
||||
|
||||
interface Row { |
||||
traceID: string; |
||||
spanID: string; |
||||
parentSpanID: string; |
||||
operationName: string; |
||||
serviceName: string; |
||||
serviceTags: string; |
||||
startTime: number; |
||||
duration: number; |
||||
logs: string; |
||||
tags: string; |
||||
} |
||||
|
||||
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: DataFrame): 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: DataFrame): { nodes: Node[]; edges: Edge[] } { |
||||
const nodes: Node[] = []; |
||||
const edges: Edge[] = []; |
||||
|
||||
const view = new DataFrameView<Row>(data); |
||||
|
||||
const traceDuration = findTraceDuration(view); |
||||
const spanMap = makeSpanMap(view); |
||||
|
||||
for (let i = 0; i < view.length; i++) { |
||||
const row = view.get(i); |
||||
|
||||
const childrenDuration = getDuration(spanMap[row.spanID].children.map((c) => spanMap[c].span)); |
||||
const selfDuration = row.duration - childrenDuration; |
||||
|
||||
nodes.push({ |
||||
[Fields.id]: row.spanID, |
||||
[Fields.title]: row.serviceName ?? '', |
||||
[Fields.subTitle]: row.operationName, |
||||
[Fields.mainStat]: `total: ${toFixedNoTrailingZeros(row.duration)}ms (${toFixedNoTrailingZeros( |
||||
(row.duration / traceDuration) * 100 |
||||
)}%)`,
|
||||
[Fields.secondaryStat]: `self: ${toFixedNoTrailingZeros(selfDuration)}ms (${toFixedNoTrailingZeros( |
||||
(selfDuration / row.duration) * 100 |
||||
)}%)`,
|
||||
[Fields.color]: selfDuration / traceDuration, |
||||
}); |
||||
|
||||
if (row.parentSpanID) { |
||||
edges.push({ |
||||
[Fields.id]: row.parentSpanID + '--' + row.spanID, |
||||
[Fields.target]: row.spanID, |
||||
[Fields.source]: row.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(view: DataFrameView<Row>): number { |
||||
let traceEndTime = 0; |
||||
let traceStartTime = Infinity; |
||||
|
||||
for (let i = 0; i < view.length; i++) { |
||||
const row = view.get(i); |
||||
|
||||
if (row.startTime < traceStartTime) { |
||||
traceStartTime = row.startTime; |
||||
} |
||||
|
||||
if (row.startTime + row.duration > traceEndTime) { |
||||
traceEndTime = row.startTime + row.duration; |
||||
} |
||||
} |
||||
|
||||
return traceEndTime - traceStartTime; |
||||
} |
||||
|
||||
/** |
||||
* Returns a map of the spans with children array for easier processing. |
||||
*/ |
||||
function makeSpanMap(view: DataFrameView<Row>): { [id: string]: { span: Row; children: string[] } } { |
||||
const spanMap: { [id: string]: { span?: Row; children: string[] } } = {}; |
||||
|
||||
for (let i = 0; i < view.length; i++) { |
||||
const row = view.get(i); |
||||
|
||||
if (!spanMap[row.spanID]) { |
||||
spanMap[row.spanID] = { |
||||
// Need copy because of how the view works
|
||||
span: { ...row }, |
||||
children: [], |
||||
}; |
||||
} else { |
||||
spanMap[row.spanID].span = { ...row }; |
||||
} |
||||
if (!spanMap[row.parentSpanID]) { |
||||
spanMap[row.parentSpanID] = { |
||||
span: undefined, |
||||
children: [row.spanID], |
||||
}; |
||||
} else { |
||||
spanMap[row.parentSpanID].children.push(row.spanID); |
||||
} |
||||
} |
||||
return spanMap as { [id: string]: { span: Row; children: string[] } }; |
||||
} |
||||
|
||||
/** |
||||
* Get non overlapping duration of the spans. |
||||
*/ |
||||
function getDuration(rows: Row[]): number { |
||||
const ranges = rows.map<[number, number]>((r) => [r.startTime, r.startTime + r.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); |
||||
} |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue