mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
d1d118a474
commit
615de9bf34
@ -1,16 +1,16 @@
|
|||||||
# Generate traffic by hitting http://localhost:8081
|
# Generate traffic by hitting http://localhost:8081
|
||||||
frontend-example:
|
frontend:
|
||||||
image: ghcr.io/openzipkin/brave-example
|
image: ghcr.io/openzipkin/brave-example
|
||||||
entrypoint: start-frontend
|
entrypoint: start-frontend
|
||||||
ports:
|
ports:
|
||||||
- 8081:8081
|
- 8081:8081
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-example:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
zipkin:
|
zipkin:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
# Serves the /api endpoint the frontend uses
|
# Serves the /api endpoint the frontend uses
|
||||||
backend-example:
|
backend:
|
||||||
image: ghcr.io/openzipkin/brave-example
|
image: ghcr.io/openzipkin/brave-example
|
||||||
entrypoint: start-backend
|
entrypoint: start-backend
|
||||||
depends_on:
|
depends_on:
|
||||||
|
118
public/app/core/utils/tracing.ts
Normal file
118
public/app/core/utils/tracing.ts
Normal file
@ -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];
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { DataFrame, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
|
import { DataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
|
||||||
import { Span, TraceResponse } from './types';
|
import { Span, TraceResponse } from './types';
|
||||||
|
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing';
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
[Fields.id]: string;
|
[Fields.id]: string;
|
||||||
@ -18,40 +19,12 @@ interface Edge {
|
|||||||
|
|
||||||
export function createGraphFrames(data: TraceResponse): DataFrame[] {
|
export function createGraphFrames(data: TraceResponse): DataFrame[] {
|
||||||
const { nodes, edges } = convertTraceToGraph(data);
|
const { nodes, edges } = convertTraceToGraph(data);
|
||||||
|
const [nodesFrame, edgesFrame] = 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
nodesFrame.add(node);
|
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) {
|
for (const edge of edges) {
|
||||||
edgesFrame.add(edge);
|
edgesFrame.add(edge);
|
||||||
}
|
}
|
||||||
@ -64,23 +37,36 @@ function convertTraceToGraph(data: TraceResponse): { nodes: Node[]; edges: Edge[
|
|||||||
const edges: Edge[] = [];
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
const traceDuration = findTraceDuration(data.spans);
|
const traceDuration = findTraceDuration(data.spans);
|
||||||
const spanMap = makeSpanMap(data.spans);
|
|
||||||
|
const spanMap = makeSpanMap((index) => {
|
||||||
|
if (index >= data.spans.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const span = data.spans[index];
|
||||||
|
return {
|
||||||
|
span,
|
||||||
|
id: span.spanID,
|
||||||
|
parentIds: span.references?.filter((r) => r.refType === 'CHILD_OF').map((r) => r.spanID) || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
for (const span of data.spans) {
|
for (const span of data.spans) {
|
||||||
const process = data.processes[span.processID];
|
const process = data.processes[span.processID];
|
||||||
const childrenDuration = getDuration(spanMap[span.spanID].children.map((c) => spanMap[c].span));
|
|
||||||
|
const ranges: Array<[number, number]> = spanMap[span.spanID].children.map((c) => {
|
||||||
|
const span = spanMap[c].span;
|
||||||
|
return [span.startTime, span.startTime + span.duration];
|
||||||
|
});
|
||||||
|
const childrenDuration = getNonOverlappingDuration(ranges);
|
||||||
const selfDuration = span.duration - childrenDuration;
|
const selfDuration = span.duration - childrenDuration;
|
||||||
|
const stats = getStats(span.duration / 1000, traceDuration / 1000, selfDuration / 1000);
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
[Fields.id]: span.spanID,
|
[Fields.id]: span.spanID,
|
||||||
[Fields.title]: process?.serviceName ?? '',
|
[Fields.title]: process?.serviceName ?? '',
|
||||||
[Fields.subTitle]: span.operationName,
|
[Fields.subTitle]: span.operationName,
|
||||||
[Fields.mainStat]: `${toFixedNoTrailingZeros(span.duration / 1000)}ms (${toFixedNoTrailingZeros(
|
[Fields.mainStat]: stats.main,
|
||||||
(span.duration / traceDuration) * 100
|
[Fields.secondaryStat]: stats.secondary,
|
||||||
)}%)`,
|
|
||||||
[Fields.secondaryStat]: `${toFixedNoTrailingZeros(selfDuration / 1000)}ms (${toFixedNoTrailingZeros(
|
|
||||||
(selfDuration / span.duration) * 100
|
|
||||||
)}%)`,
|
|
||||||
[Fields.color]: selfDuration / traceDuration,
|
[Fields.color]: selfDuration / traceDuration,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -98,10 +84,6 @@ function convertTraceToGraph(data: TraceResponse): { nodes: Node[]; edges: Edge[
|
|||||||
return { nodes, edges };
|
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.
|
* 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.
|
* Note: Seems like this should be the same as just longest span, but this is probably safer.
|
||||||
@ -122,65 +104,3 @@ function findTraceDuration(spans: Span[]): number {
|
|||||||
|
|
||||||
return traceEndTime - traceStartTime;
|
return traceEndTime - traceStartTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
@ -52,8 +52,8 @@ describe('Tempo data source', () => {
|
|||||||
{ name: 'id', values: ['4322526419282105830'] },
|
{ name: 'id', values: ['4322526419282105830'] },
|
||||||
{ name: 'title', values: ['service'] },
|
{ name: 'title', values: ['service'] },
|
||||||
{ name: 'subTitle', values: ['store.validateQueryTimeRange'] },
|
{ name: 'subTitle', values: ['store.validateQueryTimeRange'] },
|
||||||
{ name: 'mainStat', values: ['total: 14.98ms (100%)'] },
|
{ name: 'mainStat', values: ['14.98ms (100%)'] },
|
||||||
{ name: 'secondaryStat', values: ['self: 14.98ms (100%)'] },
|
{ name: 'secondaryStat', values: ['14.98ms (100%)'] },
|
||||||
{ name: 'color', values: [1.000007560204647] },
|
{ name: 'color', values: [1.000007560204647] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@ describe('createGraphFrames', () => {
|
|||||||
id: '4322526419282105830',
|
id: '4322526419282105830',
|
||||||
title: 'loki-all',
|
title: 'loki-all',
|
||||||
subTitle: 'store.validateQueryTimeRange',
|
subTitle: 'store.validateQueryTimeRange',
|
||||||
mainStat: 'total: 0ms (0.02%)',
|
mainStat: '0ms (0.02%)',
|
||||||
secondaryStat: 'self: 0ms (100%)',
|
secondaryStat: '0ms (100%)',
|
||||||
color: 0.00021968356127648162,
|
color: 0.00021968356127648162,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -23,8 +23,8 @@ describe('createGraphFrames', () => {
|
|||||||
id: '4450900759028499335',
|
id: '4450900759028499335',
|
||||||
title: 'loki-all',
|
title: 'loki-all',
|
||||||
subTitle: 'HTTP GET - loki_api_v1_query_range',
|
subTitle: 'HTTP GET - loki_api_v1_query_range',
|
||||||
mainStat: 'total: 18.21ms (100%)',
|
mainStat: '18.21ms (100%)',
|
||||||
secondaryStat: 'self: 3.22ms (17.71%)',
|
secondaryStat: '3.22ms (17.71%)',
|
||||||
color: 0.17707117189595056,
|
color: 0.17707117189595056,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -44,8 +44,8 @@ describe('createGraphFrames', () => {
|
|||||||
id: '4322526419282105830',
|
id: '4322526419282105830',
|
||||||
title: 'loki-all',
|
title: 'loki-all',
|
||||||
subTitle: 'store.validateQueryTimeRange',
|
subTitle: 'store.validateQueryTimeRange',
|
||||||
mainStat: 'total: 14.98ms (100%)',
|
mainStat: '14.98ms (100%)',
|
||||||
secondaryStat: 'self: 14.98ms (100%)',
|
secondaryStat: '14.98ms (100%)',
|
||||||
color: 1.000007560204647,
|
color: 1.000007560204647,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
import {
|
import { DataFrame, DataFrameView, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
|
||||||
DataFrame,
|
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing';
|
||||||
DataFrameView,
|
|
||||||
FieldType,
|
|
||||||
MutableDataFrame,
|
|
||||||
NodeGraphDataFrameFieldNames as Fields,
|
|
||||||
} from '@grafana/data';
|
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
traceID: string;
|
traceID: string;
|
||||||
@ -36,40 +31,11 @@ interface Edge {
|
|||||||
|
|
||||||
export function createGraphFrames(data: DataFrame): DataFrame[] {
|
export function createGraphFrames(data: DataFrame): DataFrame[] {
|
||||||
const { nodes, edges } = convertTraceToGraph(data);
|
const { nodes, edges } = convertTraceToGraph(data);
|
||||||
|
const [nodesFrame, edgesFrame] = 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
nodesFrame.add(node);
|
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) {
|
for (const edge of edges) {
|
||||||
edgesFrame.add(edge);
|
edgesFrame.add(edge);
|
||||||
}
|
}
|
||||||
@ -84,24 +50,35 @@ function convertTraceToGraph(data: DataFrame): { nodes: Node[]; edges: Edge[] }
|
|||||||
const view = new DataFrameView<Row>(data);
|
const view = new DataFrameView<Row>(data);
|
||||||
|
|
||||||
const traceDuration = findTraceDuration(view);
|
const traceDuration = findTraceDuration(view);
|
||||||
const spanMap = makeSpanMap(view);
|
const spanMap = makeSpanMap((index) => {
|
||||||
|
if (index >= data.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const span = view.get(index);
|
||||||
|
return {
|
||||||
|
span: { ...span },
|
||||||
|
id: span.spanID,
|
||||||
|
parentIds: span.parentSpanID ? [span.parentSpanID] : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0; i < view.length; i++) {
|
for (let i = 0; i < view.length; i++) {
|
||||||
const row = view.get(i);
|
const row = view.get(i);
|
||||||
|
|
||||||
const childrenDuration = getDuration(spanMap[row.spanID].children.map((c) => spanMap[c].span));
|
const ranges: Array<[number, number]> = spanMap[row.spanID].children.map((c) => {
|
||||||
|
const span = spanMap[c].span;
|
||||||
|
return [span.startTime, span.startTime + span.duration];
|
||||||
|
});
|
||||||
|
const childrenDuration = getNonOverlappingDuration(ranges);
|
||||||
const selfDuration = row.duration - childrenDuration;
|
const selfDuration = row.duration - childrenDuration;
|
||||||
|
const stats = getStats(row.duration, traceDuration, selfDuration);
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
[Fields.id]: row.spanID,
|
[Fields.id]: row.spanID,
|
||||||
[Fields.title]: row.serviceName ?? '',
|
[Fields.title]: row.serviceName ?? '',
|
||||||
[Fields.subTitle]: row.operationName,
|
[Fields.subTitle]: row.operationName,
|
||||||
[Fields.mainStat]: `total: ${toFixedNoTrailingZeros(row.duration)}ms (${toFixedNoTrailingZeros(
|
[Fields.mainStat]: stats.main,
|
||||||
(row.duration / traceDuration) * 100
|
[Fields.secondaryStat]: stats.secondary,
|
||||||
)}%)`,
|
|
||||||
[Fields.secondaryStat]: `self: ${toFixedNoTrailingZeros(selfDuration)}ms (${toFixedNoTrailingZeros(
|
|
||||||
(selfDuration / row.duration) * 100
|
|
||||||
)}%)`,
|
|
||||||
[Fields.color]: selfDuration / traceDuration,
|
[Fields.color]: selfDuration / traceDuration,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -118,10 +95,6 @@ function convertTraceToGraph(data: DataFrame): { nodes: Node[]; edges: Edge[] }
|
|||||||
return { nodes, edges };
|
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.
|
* 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.
|
* Note: Seems like this should be the same as just longest span, but this is probably safer.
|
||||||
@ -144,66 +117,3 @@ function findTraceDuration(view: DataFrameView<Row>): number {
|
|||||||
|
|
||||||
return traceEndTime - traceStartTime;
|
return traceEndTime - traceStartTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
@ -14,6 +14,7 @@ import { serializeParams } from '../../../core/utils/fetch';
|
|||||||
import { apiPrefix } from './constants';
|
import { apiPrefix } from './constants';
|
||||||
import { ZipkinSpan } from './types';
|
import { ZipkinSpan } from './types';
|
||||||
import { transformResponse } from './utils/transforms';
|
import { transformResponse } from './utils/transforms';
|
||||||
|
import { createGraphFrames } from './utils/graphTransform';
|
||||||
|
|
||||||
export interface ZipkinQuery extends DataQuery {
|
export interface ZipkinQuery extends DataQuery {
|
||||||
query: string;
|
query: string;
|
||||||
@ -67,7 +68,7 @@ export class ZipkinDatasource extends DataSourceApi<ZipkinQuery> {
|
|||||||
|
|
||||||
function responseToDataQueryResponse(response: { data: ZipkinSpan[] }): DataQueryResponse {
|
function responseToDataQueryResponse(response: { data: ZipkinSpan[] }): DataQueryResponse {
|
||||||
return {
|
return {
|
||||||
data: response?.data ? [transformResponse(response?.data)] : [],
|
data: response?.data ? [transformResponse(response?.data), ...createGraphFrames(response?.data)] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
];
|
99
public/app/plugins/datasource/zipkin/utils/graphTransform.ts
Normal file
99
public/app/plugins/datasource/zipkin/utils/graphTransform.ts
Normal file
@ -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;
|
||||||
|
}
|
119
public/app/plugins/datasource/zipkin/utils/testResponse.ts
Normal file
119
public/app/plugins/datasource/zipkin/utils/testResponse.ts
Normal file
@ -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
Block a user