Tempo: TraceQL results as a spans list (#75660)

* Format tempo search results as spans

* Thank you test
This commit is contained in:
Andre Pereira 2023-09-29 18:34:39 +01:00 committed by GitHub
parent c08606d2c5
commit 1ce603b9e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 243 additions and 15 deletions

View File

@ -59,6 +59,10 @@ export interface TempoQuery extends common.DataQuery {
* Defines the maximum number of spans per spanset that are returned from Tempo * Defines the maximum number of spans per spanset that are returned from Tempo
*/ */
spss?: number; spss?: number;
/**
* The type of the table that is used to display the search results
*/
tableType?: SearchTableType;
} }
export const defaultTempoQuery: Partial<TempoQuery> = { export const defaultTempoQuery: Partial<TempoQuery> = {
@ -81,6 +85,14 @@ export enum SearchStreamingState {
Streaming = 'streaming', Streaming = 'streaming',
} }
/**
* The type of the table that is used to display the search results
*/
export enum SearchTableType {
Spans = 'spans',
Traces = 'traces',
}
/** /**
* static fields are pre-set in the UI, dynamic fields are added by the user * static fields are pre-set in the UI, dynamic fields are added by the user
*/ */

View File

@ -17,6 +17,12 @@ const (
SearchStreamingStateStreaming SearchStreamingState = "streaming" SearchStreamingStateStreaming SearchStreamingState = "streaming"
) )
// Defines values for SearchTableType.
const (
SearchTableTypeSpans SearchTableType = "spans"
SearchTableTypeTraces SearchTableType = "traces"
)
// Defines values for TempoQueryType. // Defines values for TempoQueryType.
const ( const (
TempoQueryTypeClear TempoQueryType = "clear" TempoQueryTypeClear TempoQueryType = "clear"
@ -65,6 +71,9 @@ type DataQuery struct {
// The state of the TraceQL streaming search query // The state of the TraceQL streaming search query
type SearchStreamingState string type SearchStreamingState string
// The type of the table that is used to display the search results
type SearchTableType string
// TempoDataQuery defines model for TempoDataQuery. // TempoDataQuery defines model for TempoDataQuery.
type TempoDataQuery = map[string]any type TempoDataQuery = map[string]any
@ -128,6 +137,9 @@ type TempoQuery struct {
// Defines the maximum number of spans per spanset that are returned from Tempo // Defines the maximum number of spans per spanset that are returned from Tempo
Spss *int64 `json:"spss,omitempty"` Spss *int64 `json:"spss,omitempty"`
// The type of the table that is used to display the search results
TableType *SearchTableType `json:"tableType,omitempty"`
} }
// TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility // TempoQueryType search = Loki search, nativeSearch = Tempo search for backwards compatibility

View File

@ -49,6 +49,8 @@ composableKinds: DataQuery: {
filters: [...#TraceqlFilter] filters: [...#TraceqlFilter]
// Filters that are used to query the metrics summary // Filters that are used to query the metrics summary
groupBy?: [...#TraceqlFilter] groupBy?: [...#TraceqlFilter]
// The type of the table that is used to display the search results
tableType?: #SearchTableType
} @cuetsy(kind="interface") @grafana(TSVeneer="type") } @cuetsy(kind="interface") @grafana(TSVeneer="type")
// search = Loki search, nativeSearch = Tempo search for backwards compatibility // search = Loki search, nativeSearch = Tempo search for backwards compatibility
@ -57,6 +59,9 @@ composableKinds: DataQuery: {
// The state of the TraceQL streaming search query // The state of the TraceQL streaming search query
#SearchStreamingState: "pending" | "streaming" | "done" | "error" @cuetsy(kind="enum") #SearchStreamingState: "pending" | "streaming" | "done" | "error" @cuetsy(kind="enum")
// The type of the table that is used to display the search results
#SearchTableType: "traces" | "spans" @cuetsy(kind="enum")
// static fields are pre-set in the UI, dynamic fields are added by the user // static fields are pre-set in the UI, dynamic fields are added by the user
#TraceqlSearchScope: "intrinsic" | "unscoped" | "resource" | "span" @cuetsy(kind="enum") #TraceqlSearchScope: "intrinsic" | "unscoped" | "resource" | "span" @cuetsy(kind="enum")
#TraceqlFilter: { #TraceqlFilter: {

View File

@ -56,6 +56,10 @@ export interface TempoQuery extends common.DataQuery {
* Defines the maximum number of spans per spanset that are returned from Tempo * Defines the maximum number of spans per spanset that are returned from Tempo
*/ */
spss?: number; spss?: number;
/**
* The type of the table that is used to display the search results
*/
tableType?: SearchTableType;
} }
export const defaultTempoQuery: Partial<TempoQuery> = { export const defaultTempoQuery: Partial<TempoQuery> = {
@ -78,6 +82,14 @@ export enum SearchStreamingState {
Streaming = 'streaming', Streaming = 'streaming',
} }
/**
* The type of the table that is used to display the search results
*/
export enum SearchTableType {
Spans = 'spans',
Traces = 'traces',
}
/** /**
* static fields are pre-set in the UI, dynamic fields are added by the user * static fields are pre-set in the UI, dynamic fields are added by the user
*/ */

View File

@ -60,7 +60,7 @@ import {
transformTraceList, transformTraceList,
transformFromOTLP as transformFromOTEL, transformFromOTLP as transformFromOTEL,
createTableFrameFromSearch, createTableFrameFromSearch,
createTableFrameFromTraceQlQuery, formatTraceQLResponse,
} from './resultTransformer'; } from './resultTransformer';
import { doTempoChannelStream } from './streaming'; import { doTempoChannelStream } from './streaming';
import { SearchQueryParams, TempoQuery, TempoJsonData } from './types'; import { SearchQueryParams, TempoQuery, TempoJsonData } from './types';
@ -359,7 +359,11 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}).pipe( }).pipe(
map((response) => { map((response) => {
return { return {
data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings), data: formatTraceQLResponse(
response.data.traces,
this.instanceSettings,
targets.traceql[0].tableType
),
}; };
}), }),
catchError((err) => { catchError((err) => {
@ -413,7 +417,11 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}).pipe( }).pipe(
map((response) => { map((response) => {
return { return {
data: createTableFrameFromTraceQlQuery(response.data.traces, this.instanceSettings), data: formatTraceQLResponse(
response.data.traces,
this.instanceSettings,
targets.traceqlSearch[0].tableType
),
}; };
}), }),
catchError((err) => { catchError((err) => {

View File

@ -157,7 +157,7 @@ describe('createTableFrameFromTraceQlQuery()', () => {
expect(frame.fields[5].values[0][0].fields[0].values[0]).toBe('b1586c3c8c34d'); expect(frame.fields[5].values[0][0].fields[0].values[0]).toBe('b1586c3c8c34d');
expect(frame.fields[5].values[0][0].fields[1].name).toBe('spanID'); expect(frame.fields[5].values[0][0].fields[1].name).toBe('spanID');
expect(frame.fields[5].values[0][0].fields[1].values[0]).toBe('162a4adae63b61f1'); expect(frame.fields[5].values[0][0].fields[1].values[0]).toBe('162a4adae63b61f1');
expect(frame.fields[5].values[0][0].fields[2].name).toBe('spanStartTime'); expect(frame.fields[5].values[0][0].fields[2].name).toBe('time');
expect(frame.fields[5].values[0][0].fields[2].values[0]).toBe(1666188214303.201); expect(frame.fields[5].values[0][0].fields[2].values[0]).toBe(1666188214303.201);
expect(frame.fields[5].values[0][0].fields[4].name).toBe('http.method'); expect(frame.fields[5].values[0][0].fields[4].name).toBe('http.method');
expect(frame.fields[5].values[0][0].fields[4].values[0]).toBe('GET'); expect(frame.fields[5].values[0][0].fields[4].values[0]).toBe('GET');
@ -170,7 +170,7 @@ describe('createTableFrameFromTraceQlQuery()', () => {
expect(frame.fields[5].values[1][0].fields[0].values[0]).toBe('9161e77388f3e'); expect(frame.fields[5].values[1][0].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[5].values[1][0].fields[1].name).toBe('spanID'); expect(frame.fields[5].values[1][0].fields[1].name).toBe('spanID');
expect(frame.fields[5].values[1][0].fields[1].values[0]).toBe('3b9a5c222d3ddd8f'); expect(frame.fields[5].values[1][0].fields[1].values[0]).toBe('3b9a5c222d3ddd8f');
expect(frame.fields[5].values[1][0].fields[2].name).toBe('spanStartTime'); expect(frame.fields[5].values[1][0].fields[2].name).toBe('time');
expect(frame.fields[5].values[1][0].fields[2].values[0]).toBe(1666187875397.7212); expect(frame.fields[5].values[1][0].fields[2].values[0]).toBe(1666187875397.7212);
expect(frame.fields[5].values[1][0].fields[4].name).toBe('by(resource.service.name)'); expect(frame.fields[5].values[1][0].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[5].values[1][0].fields[4].values[0]).toBe('db'); expect(frame.fields[5].values[1][0].fields[4].values[0]).toBe('db');
@ -185,7 +185,7 @@ describe('createTableFrameFromTraceQlQuery()', () => {
expect(frame.fields[5].values[1][1].fields[0].values[0]).toBe('9161e77388f3e'); expect(frame.fields[5].values[1][1].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[5].values[1][1].fields[1].name).toBe('spanID'); expect(frame.fields[5].values[1][1].fields[1].name).toBe('spanID');
expect(frame.fields[5].values[1][1].fields[1].values[0]).toBe('894d90db6b5807f'); expect(frame.fields[5].values[1][1].fields[1].values[0]).toBe('894d90db6b5807f');
expect(frame.fields[5].values[1][1].fields[2].name).toBe('spanStartTime'); expect(frame.fields[5].values[1][1].fields[2].name).toBe('time');
expect(frame.fields[5].values[1][1].fields[2].values[0]).toBe(1666187875393.293); expect(frame.fields[5].values[1][1].fields[2].values[0]).toBe(1666187875393.293);
expect(frame.fields[5].values[1][1].fields[4].name).toBe('by(resource.service.name)'); expect(frame.fields[5].values[1][1].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[5].values[1][1].fields[4].values[0]).toBe('app'); expect(frame.fields[5].values[1][1].fields[4].values[0]).toBe('app');

View File

@ -18,6 +18,7 @@ import {
createTheme, createTheme,
} from '@grafana/data'; } from '@grafana/data';
import { SearchTableType } from './dataquery.gen';
import { createGraphFrames } from './graphTransform'; import { createGraphFrames } from './graphTransform';
import { Span, SpanAttributes, Spanset, TraceSearchMetadata } from './types'; import { Span, SpanAttributes, Spanset, TraceSearchMetadata } from './types';
@ -564,6 +565,17 @@ function transformToTraceData(data: TraceSearchMetadata) {
}; };
} }
export function formatTraceQLResponse(
data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings,
tableType?: SearchTableType
) {
if (tableType === SearchTableType.Spans) {
return createTableFrameFromTraceQlQueryAsSpans(data, instanceSettings);
}
return createTableFrameFromTraceQlQuery(data, instanceSettings);
}
export function createTableFrameFromTraceQlQuery( export function createTableFrameFromTraceQlQuery(
data: TraceSearchMetadata[], data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings instanceSettings: DataSourceInstanceSettings
@ -660,6 +672,147 @@ export function createTableFrameFromTraceQlQuery(
return [frame]; return [frame];
} }
export function createTableFrameFromTraceQlQueryAsSpans(
data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings
): DataFrame[] {
const spanDynamicAttrs: Record<string, FieldDTO> = {};
let hasNameAttribute = false;
data.forEach(
(t) =>
t.spanSets?.forEach((ss) => {
ss.attributes?.forEach((attr) => {
spanDynamicAttrs[attr.key] = {
name: attr.key,
type: FieldType.string,
config: { displayNameFromDS: attr.key },
};
});
ss.spans.forEach((span) => {
if (span.name) {
hasNameAttribute = true;
}
span.attributes?.forEach((attr) => {
spanDynamicAttrs[attr.key] = {
name: attr.key,
type: FieldType.string,
config: { displayNameFromDS: attr.key },
};
});
});
})
);
const frame = new MutableDataFrame({
name: 'Spans',
refId: 'traces',
fields: [
{
name: 'traceIdHidden',
type: FieldType.string,
config: {
custom: { hidden: true },
},
},
{
name: 'traceService',
type: FieldType.string,
config: {
displayNameFromDS: 'Trace Service',
custom: {
width: 200,
},
},
},
{
name: 'traceName',
type: FieldType.string,
config: {
displayNameFromDS: 'Trace Name',
custom: {
width: 200,
},
},
},
{
name: 'spanID',
type: FieldType.string,
config: {
unit: 'string',
displayNameFromDS: 'Span ID',
custom: {
width: 200,
},
links: [
{
title: 'Span: ${__value.raw}',
url: '',
internal: {
datasourceUid: instanceSettings.uid,
datasourceName: instanceSettings.name,
query: {
query: '${__data.fields.traceIdHidden}',
queryType: 'traceql',
},
panelsState: {
trace: {
spanId: '${__value.raw}',
},
},
},
},
],
},
},
{
name: 'time',
type: FieldType.time,
config: {
displayNameFromDS: 'Start time',
},
},
{
name: 'name',
type: FieldType.string,
config: { displayNameFromDS: 'Name', custom: { hidden: !hasNameAttribute } },
},
...Object.values(spanDynamicAttrs).sort((a, b) => a.name.localeCompare(b.name)),
{
name: 'duration',
type: FieldType.number,
config: {
displayNameFromDS: 'Duration',
unit: 'ns',
custom: {
width: 120,
},
},
},
],
meta: {
preferredVisualisationType: 'table',
},
});
if (!data?.length) {
return [frame];
}
data
// Show the most recent traces
.sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000)
.forEach((trace) => {
trace.spanSets?.forEach((spanSet) => {
spanSet.spans.forEach((span) => {
frame.add(transformSpanToTraceData(span, spanSet, trace));
});
});
});
return [frame];
}
const traceSubFrame = ( const traceSubFrame = (
trace: TraceSearchMetadata, trace: TraceSearchMetadata,
spanSet: Spanset, spanSet: Spanset,
@ -728,7 +881,7 @@ const traceSubFrame = (
}, },
}, },
{ {
name: 'spanStartTime', name: 'time',
type: FieldType.time, type: FieldType.time,
config: { config: {
displayNameFromDS: 'Start time', displayNameFromDS: 'Start time',
@ -766,7 +919,7 @@ const traceSubFrame = (
} }
spanSet.spans.forEach((span) => { spanSet.spans.forEach((span) => {
subFrame.add(transformSpanToTraceData(span, spanSet, trace.traceID)); subFrame.add(transformSpanToTraceData(span, spanSet, trace));
}); });
return subFrame; return subFrame;
@ -781,11 +934,15 @@ interface TraceTableData {
traceDuration?: number; traceDuration?: number;
} }
function transformSpanToTraceData(span: Span, spanSet: Spanset, traceID: string): TraceTableData { function transformSpanToTraceData(span: Span, spanSet: Spanset, trace: TraceSearchMetadata): TraceTableData {
const spanStartTimeUnixMs = parseInt(span.startTimeUnixNano, 10) / 1000000;
const data: TraceTableData = { const data: TraceTableData = {
traceIdHidden: traceID, traceIdHidden: trace.traceID,
traceService: trace.rootServiceName || '',
traceName: trace.rootTraceName || '',
spanID: span.spanID, spanID: span.spanID,
spanStartTime: parseInt(span.startTimeUnixNano, 10) / 1000000, time: spanStartTimeUnixMs,
duration: parseInt(span.durationNanos, 10), duration: parseInt(span.durationNanos, 10),
name: span.name, name: span.name,
}; };

View File

@ -18,7 +18,7 @@ import { getGrafanaLiveSrv } from '@grafana/runtime';
import { SearchStreamingState } from './dataquery.gen'; import { SearchStreamingState } from './dataquery.gen';
import { DEFAULT_SPSS, TempoDatasource } from './datasource'; import { DEFAULT_SPSS, TempoDatasource } from './datasource';
import { createTableFrameFromTraceQlQuery } from './resultTransformer'; import { formatTraceQLResponse } from './resultTransformer';
import { SearchMetrics, TempoJsonData, TempoQuery } from './types'; import { SearchMetrics, TempoJsonData, TempoQuery } from './types';
export async function getLiveStreamKey(): Promise<string> { export async function getLiveStreamKey(): Promise<string> {
return uuidv4(); return uuidv4();
@ -76,7 +76,7 @@ export function doTempoChannelStream(
frames = [ frames = [
metricsDataFrame(metrics, frameState, elapsedTime), metricsDataFrame(metrics, frameState, elapsedTime),
...createTableFrameFromTraceQlQuery(traces, instanceSettings), ...formatTraceQLResponse(traces, instanceSettings, query.tableType),
]; ];
} }
return { return {

View File

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { EditorField, EditorRow } from '@grafana/experimental'; import { EditorField, EditorRow } from '@grafana/experimental';
import { AutoSizeInput } from '@grafana/ui'; import { AutoSizeInput, RadioButtonGroup } from '@grafana/ui';
import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup'; import { QueryOptionGroup } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryOptionGroup';
import { SearchTableType } from '../dataquery.gen';
import { DEFAULT_LIMIT, DEFAULT_SPSS } from '../datasource'; import { DEFAULT_LIMIT, DEFAULT_SPSS } from '../datasource';
import { TempoQuery } from '../types'; import { TempoQuery } from '../types';
@ -17,14 +18,25 @@ export const TempoQueryBuilderOptions = React.memo<Props>(({ onChange, query })
query.limit = DEFAULT_LIMIT; query.limit = DEFAULT_LIMIT;
} }
if (!query.hasOwnProperty('tableType')) {
query.tableType = SearchTableType.Traces;
}
const onLimitChange = (e: React.FormEvent<HTMLInputElement>) => { const onLimitChange = (e: React.FormEvent<HTMLInputElement>) => {
onChange({ ...query, limit: parseInt(e.currentTarget.value, 10) }); onChange({ ...query, limit: parseInt(e.currentTarget.value, 10) });
}; };
const onSpssChange = (e: React.FormEvent<HTMLInputElement>) => { const onSpssChange = (e: React.FormEvent<HTMLInputElement>) => {
onChange({ ...query, spss: parseInt(e.currentTarget.value, 10) }); onChange({ ...query, spss: parseInt(e.currentTarget.value, 10) });
}; };
const onTableTypeChange = (val: SearchTableType) => {
onChange({ ...query, tableType: val });
};
const collapsedInfoList = [`Limit: ${query.limit || DEFAULT_LIMIT}`, `Spans Limit: ${query.spss || DEFAULT_SPSS}`]; const collapsedInfoList = [
`Limit: ${query.limit || DEFAULT_LIMIT}`,
`Spans Limit: ${query.spss || DEFAULT_SPSS}`,
`Table Format: ${query.tableType === SearchTableType.Traces ? 'Traces' : 'Spans'}`,
];
return ( return (
<> <>
@ -52,6 +64,16 @@ export const TempoQueryBuilderOptions = React.memo<Props>(({ onChange, query })
value={query.spss} value={query.spss}
/> />
</EditorField> </EditorField>
<EditorField label="Table Format" tooltip="How the query data should be displayed in the results table">
<RadioButtonGroup
options={[
{ label: 'Traces', value: SearchTableType.Traces },
{ label: 'Spans', value: SearchTableType.Spans },
]}
value={query.tableType}
onChange={onTableTypeChange}
/>
</EditorField>
</QueryOptionGroup> </QueryOptionGroup>
</EditorRow> </EditorRow>
</> </>