diff --git a/docs/sources/explore/explore-inspector.md b/docs/sources/explore/explore-inspector.md index f09d066e960..46576ff6411 100644 --- a/docs/sources/explore/explore-inspector.md +++ b/docs/sources/explore/explore-inspector.md @@ -57,6 +57,14 @@ Grafana generates a TXT file in your default browser download location. You can 1. Inspect the log query results as described above. 1. Click **Download logs**. +### Download trace results + +Based on the data source type, Grafana generates a JSON file for the trace results in one of the supported formats: Jaeger, Zipkin, or OTLP formats. + +1. Open the inspector. +1. Inspect the trace query results [as described above](#inspect-raw-query-results). +1. Click **Download traces**. + ### Inspect query performance The Stats tab displays statistics that tell you how long your query takes, how many queries you send, and the number of rows returned. This information can help you troubleshoot your queries, especially if any of the numbers are unexpectedly high or low. diff --git a/public/app/features/inspector/InspectDataTab.test.tsx b/public/app/features/inspector/InspectDataTab.test.tsx index ee2f79794bd..b751871c3ff 100644 --- a/public/app/features/inspector/InspectDataTab.test.tsx +++ b/public/app/features/inspector/InspectDataTab.test.tsx @@ -83,5 +83,63 @@ describe('InspectDataTab', () => { render(); expect(screen.queryByText(/Download logs/i)).not.toBeInTheDocument(); }); + it('should show download traces button if traces data', () => { + const dataWithtraces = ([ + { + name: 'Data frame with traces', + fields: [ + { 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] }, + ], + length: 2, + meta: { + preferredVisualisationType: 'trace', + custom: { + traceFormat: 'jaeger', + }, + }, + }, + ] as unknown) as DataFrame[]; + render(); + expect(screen.getByText(/Download traces/i)).toBeInTheDocument(); + }); + it('should not show download traces button if no traces data', () => { + render(); + expect(screen.queryByText(/Download traces/i)).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/inspector/InspectDataTab.tsx b/public/app/features/inspector/InspectDataTab.tsx index 8fceda77382..0dbf2c8b566 100644 --- a/public/app/features/inspector/InspectDataTab.tsx +++ b/public/app/features/inspector/InspectDataTab.tsx @@ -8,6 +8,7 @@ import { DataTransformerID, dateTimeFormat, dateTimeFormatISO, + MutableDataFrame, SelectableValue, toCSV, transformDataFrame, @@ -22,6 +23,9 @@ import { css } from '@emotion/css'; import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner'; import { PanelModel } from 'app/features/dashboard/state'; import { dataFrameToLogsModel } from 'app/core/logs_model'; +import { transformToJaeger } from 'app/plugins/datasource/jaeger/responseTransform'; +import { transformToZipkin } from 'app/plugins/datasource/zipkin/utils/transforms'; +import { transformToOTLP } from 'app/plugins/datasource/tempo/resultTransformer'; interface Props { isLoading: boolean; @@ -122,6 +126,48 @@ export class InspectDataTab extends PureComponent { saveAs(blob, fileName); }; + exportTracesAsJson = () => { + const { data, panel } = this.props; + if (!data) { + return; + } + + for (const df of data) { + // Only export traces + if (df.meta?.preferredVisualisationType !== 'trace') { + continue; + } + + switch (df.meta?.custom?.traceFormat) { + case 'jaeger': { + let res = transformToJaeger(new MutableDataFrame(df)); + this.saveTraceJson(res, panel); + break; + } + case 'zipkin': { + let res = transformToZipkin(new MutableDataFrame(df)); + this.saveTraceJson(res, panel); + break; + } + case 'otlp': + default: { + let res = transformToOTLP(new MutableDataFrame(df)); + this.saveTraceJson(res, panel); + break; + } + } + } + }; + + saveTraceJson = (json: any, panel?: PanelModel) => { + const blob = new Blob([JSON.stringify(json)], { + type: 'application/json', + }); + const displayTitle = panel ? panel.getDisplayTitle() : 'Explore'; + const fileName = `${displayTitle}-traces-${dateTimeFormat(new Date())}.json`; + saveAs(blob, fileName); + }; + onDataFrameChange = (item: SelectableValue) => { this.setState({ transformId: @@ -180,6 +226,7 @@ export class InspectDataTab extends PureComponent { const index = !dataFrames[dataFrameIndex] ? 0 : dataFrameIndex; const dataFrame = dataFrames[index]; const hasLogs = dataFrames.some((df) => df?.meta?.preferredVisualisationType === 'logs'); + const hasTraces = dataFrames.some((df) => df?.meta?.preferredVisualisationType === 'trace'); return ( @@ -218,6 +265,18 @@ export class InspectDataTab extends PureComponent { Download logs )} + {hasTraces && ( + + Download traces + + )} diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index b1fccb96792..85c776bebdc 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -171,5 +171,8 @@ const emptyTraceDataFrame = new MutableDataFrame({ ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'jaeger', + }, }, }); diff --git a/public/app/plugins/datasource/jaeger/responseTransform.test.ts b/public/app/plugins/datasource/jaeger/responseTransform.test.ts index ca1ee03d97e..b24d01f7a3b 100644 --- a/public/app/plugins/datasource/jaeger/responseTransform.test.ts +++ b/public/app/plugins/datasource/jaeger/responseTransform.test.ts @@ -1,9 +1,16 @@ -import { createTraceFrame } from './responseTransform'; +import { createTraceFrame, transformToJaeger } from './responseTransform'; import { testResponse, testResponseDataFrameFields } from './testResponse'; +import { MutableDataFrame } from '@grafana/data'; describe('createTraceFrame', () => { it('creates data frame from jaeger response', () => { const dataFrame = createTraceFrame(testResponse); expect(dataFrame.fields).toMatchObject(testResponseDataFrameFields); }); + + it('transforms to jaeger format from data frame', () => { + const dataFrame = createTraceFrame(testResponse); + const response = transformToJaeger(new MutableDataFrame(dataFrame)); + expect(response).toMatchObject({ data: [testResponse] }); + }); }); diff --git a/public/app/plugins/datasource/jaeger/responseTransform.ts b/public/app/plugins/datasource/jaeger/responseTransform.ts index 752fb876e8d..f15763f1ec6 100644 --- a/public/app/plugins/datasource/jaeger/responseTransform.ts +++ b/public/app/plugins/datasource/jaeger/responseTransform.ts @@ -1,6 +1,13 @@ -import { DataFrame, DataSourceInstanceSettings, FieldType, MutableDataFrame, TraceSpanRow } from '@grafana/data'; +import { + DataFrame, + DataSourceInstanceSettings, + FieldType, + MutableDataFrame, + TraceLog, + TraceSpanRow, +} from '@grafana/data'; import { transformTraceData } from '@jaegertracing/jaeger-ui-components'; -import { Span, TraceProcess, TraceResponse } from './types'; +import { JaegerResponse, Span, TraceProcess, TraceResponse } from './types'; export function createTraceFrame(data: TraceResponse): DataFrame { const spans = data.spans.map((s) => toSpanRow(s, data.processes)); @@ -22,6 +29,9 @@ export function createTraceFrame(data: TraceResponse): DataFrame { ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'jaeger', + }, }, }); @@ -107,3 +117,62 @@ function transformToTraceData(data: TraceResponse) { traceName: traceData.traceName, }; } + +export function transformToJaeger(data: MutableDataFrame): JaegerResponse { + let traceResponse: TraceResponse = { + traceID: '', + spans: [], + processes: {}, + warnings: null, + }; + let processes: string[] = []; + + for (let i = 0; i < data.length; i++) { + const span = data.get(i); + + // Set traceID + if (!traceResponse.traceID) { + traceResponse.traceID = span.traceID; + } + + // Create process if doesn't exist + if (!processes.find((p) => p === span.serviceName)) { + processes.push(span.serviceName); + traceResponse.processes[`p${processes.length}`] = { + serviceName: span.serviceName, + tags: span.serviceTags, + }; + } + + // Create span + traceResponse.spans.push({ + traceID: span.traceID, + spanID: span.spanID, + duration: span.duration * 1000, + references: span.parentSpanID + ? [ + { + refType: 'CHILD_OF', + spanID: span.parentSpanID, + traceID: span.traceID, + }, + ] + : [], + flags: 0, + logs: span.logs.map((l: TraceLog) => ({ + ...l, + timestamp: l.timestamp * 1000, + })), + operationName: span.operationName, + processID: + Object.keys(traceResponse.processes).find( + (key) => traceResponse.processes[key].serviceName === span.serviceName + ) || '', + startTime: span.startTime * 1000, + tags: span.tags, + warnings: span.warnings ? span.warnings : null, + }); + } + + return { data: [traceResponse], total: 0, limit: 0, offset: 0, errors: null }; +} diff --git a/public/app/plugins/datasource/jaeger/types.ts b/public/app/plugins/datasource/jaeger/types.ts index 7d1b203532c..45ee89fbeb0 100644 --- a/public/app/plugins/datasource/jaeger/types.ts +++ b/public/app/plugins/datasource/jaeger/types.ts @@ -64,3 +64,11 @@ export type JaegerQuery = { } & DataQuery; export type JaegerQueryType = 'search' | 'upload'; + +export type JaegerResponse = { + data: TraceResponse[]; + total: number; + limit: number; + offset: number; + errors?: string[] | null; +}; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 29325c471a7..f501fae2849 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -84,7 +84,10 @@ export class TempoDatasource extends DataSourceWithBackend field.matcherRegex) || []; if (!traceLinkMatcher || traceLinkMatcher.length === 0) { return throwError( - 'No Loki datasource configured for search. Set up Derived Fields for traces in a Loki datasource settings and link it to this Tempo datasource.' + () => + new Error( + 'No Loki datasource configured for search. Set up Derived Fields for traces in a Loki datasource settings and link it to this Tempo datasource.' + ) ); } else { return (linkedDatasource.query(linkedRequest) as Observable).pipe( diff --git a/public/app/plugins/datasource/tempo/resultTransformer.test.ts b/public/app/plugins/datasource/tempo/resultTransformer.test.ts index c73b794f18c..b82a7f500a7 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.test.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.test.ts @@ -1,5 +1,6 @@ import { FieldType, MutableDataFrame } from '@grafana/data'; -import { createTableFrame } from './resultTransformer'; +import { createTableFrame, transformToOTLP } from './resultTransformer'; +import { otlpDataFrame, otlpResponse } from './testResponse'; describe('transformTraceList()', () => { const lokiDataFrame = new MutableDataFrame({ @@ -35,3 +36,10 @@ describe('transformTraceList()', () => { expect(frame.fields[1].values.get(1)).toBe('as'); }); }); + +describe('transformToOTLP()', () => { + test('transforms dataframe to OTLP format', () => { + const otlp = transformToOTLP(otlpDataFrame); + expect(otlp).toMatchObject(otlpResponse); + }); +}); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index 45a3d13d5aa..cb4b3dc7abc 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -10,7 +10,7 @@ import { TraceLog, TraceSpanRow, } from '@grafana/data'; -import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { SpanKind, SpanStatus, SpanStatusCode } from '@opentelemetry/api'; import { collectorTypes } from '@opentelemetry/exporter-collector'; import { ResourceAttributes } from '@opentelemetry/semantic-conventions'; import { createGraphFrames } from './graphTransform'; @@ -119,6 +119,12 @@ function transformBase64IDToHexString(base64: string) { return id.length > 16 ? id.slice(16) : id; } +function transformHexStringToBase64ID(hex: string) { + const buffer = Buffer.from(hex, 'hex'); + const id = buffer.toString('base64'); + return id; +} + function getAttributeValue(value: collectorTypes.opentelemetryProto.common.v1.AnyValue): any { if (value.stringValue) { return value.stringValue; @@ -248,6 +254,9 @@ export function transformFromOTLP( ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'otlp', + }, }, }); try { @@ -277,6 +286,183 @@ export function transformFromOTLP( return { data: [frame, ...createGraphFrames(frame)] }; } +/** + * Transforms trace dataframes to the OpenTelemetry format + */ +export function transformToOTLP( + data: MutableDataFrame +): { batches: collectorTypes.opentelemetryProto.trace.v1.ResourceSpans[] } { + let result: { batches: collectorTypes.opentelemetryProto.trace.v1.ResourceSpans[] } = { + batches: [], + }; + + // Lookup object to see which batch contains spans for which services + let services: { [key: string]: number } = {}; + + for (let i = 0; i < data.length; i++) { + const span = data.get(i); + + // Group spans based on service + if (!services[span.serviceName]) { + services[span.serviceName] = result.batches.length; + result.batches.push({ + resource: { + attributes: [], + droppedAttributesCount: 0, + }, + instrumentationLibrarySpans: [ + { + spans: [], + }, + ], + }); + } + + let batchIndex = services[span.serviceName]; + + // Populate resource attributes from service tags + if (result.batches[batchIndex].resource!.attributes.length === 0) { + result.batches[batchIndex].resource!.attributes = tagsToAttributes(span.serviceTags); + } + + // Populate instrumentation library if it exists + if (!result.batches[batchIndex].instrumentationLibrarySpans[0].instrumentationLibrary) { + let libraryName = span.tags.find((t: TraceKeyValuePair) => t.key === 'otel.library.name')?.value; + if (libraryName) { + result.batches[batchIndex].instrumentationLibrarySpans[0].instrumentationLibrary = { + name: libraryName, + version: span.tags.find((t: TraceKeyValuePair) => t.key === 'otel.library.version')?.value, + }; + } + } + + result.batches[batchIndex].instrumentationLibrarySpans[0].spans.push({ + traceId: transformHexStringToBase64ID(span.traceID.padStart(32, '0')), + spanId: transformHexStringToBase64ID(span.spanID), + traceState: '', + parentSpanId: transformHexStringToBase64ID(span.parentSpanID || ''), + name: span.operationName, + kind: getOTLPSpanKind(span.tags) as any, + startTimeUnixNano: span.startTime * 1000000, + endTimeUnixNano: (span.startTime + span.duration) * 1000000, + attributes: tagsToAttributes(span.tags), + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + status: getOTLPStatus(span.tags), + events: getOTLPEvents(span.logs), + }); + } + + return result; +} + +function getOTLPSpanKind(tags: TraceKeyValuePair[]): string | undefined { + let spanKind = undefined; + const spanKindTagValue = tags.find((t) => t.key === 'span.kind')?.value; + switch (spanKindTagValue) { + case 'server': + spanKind = 'SPAN_KIND_SERVER'; + break; + case 'client': + spanKind = 'SPAN_KIND_CLIENT'; + break; + case 'producer': + spanKind = 'SPAN_KIND_PRODUCER'; + break; + case 'consumer': + spanKind = 'SPAN_KIND_CONSUMER'; + break; + } + + return spanKind; +} + +/** + * Converts key-value tags to OTLP attributes and removes tags added by Grafana + */ +function tagsToAttributes(tags: TraceKeyValuePair[]): collectorTypes.opentelemetryProto.common.v1.KeyValue[] { + return tags + .filter( + (t) => + ![ + 'span.kind', + 'otel.library.name', + 'otel.libary.version', + 'otel.status_description', + 'otel.status_code', + ].includes(t.key) + ) + .reduce( + (attributes, tag) => [...attributes, { key: tag.key, value: toAttributeValue(tag) }], + [] + ); +} + +/** + * Returns the correct OTLP AnyValue based on the value of the tag value + */ +function toAttributeValue(tag: TraceKeyValuePair): collectorTypes.opentelemetryProto.common.v1.AnyValue { + if (typeof tag.value === 'string') { + return { stringValue: tag.value }; + } else if (typeof tag.value === 'boolean') { + return { boolValue: tag.value }; + } else if (typeof tag.value === 'number') { + if (tag.value % 1 === 0) { + return { intValue: tag.value }; + } else { + return { doubleValue: tag.value }; + } + } else if (typeof tag.value === 'object') { + if (Array.isArray(tag.value)) { + const values: collectorTypes.opentelemetryProto.common.v1.AnyValue[] = []; + for (const val of tag.value) { + values.push(toAttributeValue(val)); + } + + return { arrayValue: { values } }; + } + } + return { stringValue: tag.value }; +} + +function getOTLPStatus(tags: TraceKeyValuePair[]): SpanStatus | undefined { + let status = undefined; + const statusCodeTag = tags.find((t) => t.key === 'otel.status_code'); + if (statusCodeTag) { + status = { + code: statusCodeTag.value, + message: tags.find((t) => t.key === 'otel_status_description')?.value, + }; + } + + return status; +} + +function getOTLPEvents(logs: TraceLog[]): collectorTypes.opentelemetryProto.trace.v1.Span.Event[] | undefined { + if (!logs.length) { + return undefined; + } + + let events: collectorTypes.opentelemetryProto.trace.v1.Span.Event[] = []; + for (const log of logs) { + let event: collectorTypes.opentelemetryProto.trace.v1.Span.Event = { + timeUnixNano: log.timestamp * 1000000, + attributes: [], + droppedAttributesCount: 0, + name: '', + }; + for (const field of log.fields) { + event.attributes!.push({ + key: field.key, + value: toAttributeValue(field), + }); + } + events.push(event); + } + return events; +} + export function transformTrace(response: DataQueryResponse): DataQueryResponse { // We need to parse some of the fields which contain stringified json. // Seems like we can't just map the values as the frame we got from backend has some default processing @@ -400,6 +586,9 @@ const emptyDataQueryResponse = { ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'otlp', + }, }, }), ], diff --git a/public/app/plugins/datasource/tempo/testResponse.ts b/public/app/plugins/datasource/tempo/testResponse.ts index adc86bec8a5..e8e2710955e 100644 --- a/public/app/plugins/datasource/tempo/testResponse.ts +++ b/public/app/plugins/datasource/tempo/testResponse.ts @@ -1848,3 +1848,194 @@ export const bigResponse = new MutableDataFrame({ }, ], }); + +export const otlpDataFrame = new MutableDataFrame({ + meta: { + preferredVisualisationType: 'trace', + custom: { + traceFormat: 'otlp', + }, + }, + fields: [ + { + name: 'traceID', + type: 'string', + config: {}, + values: ['60ba2abb44f13eae'], + state: { + displayName: 'traceID', + }, + }, + { + name: 'spanID', + type: 'string', + config: {}, + values: ['726b5e30102fc0d0'], + state: { + displayName: 'spanID', + }, + }, + { + name: 'parentSpanID', + type: 'string', + config: {}, + values: ['398f0f21a3db99ae'], + state: { + displayName: 'parentSpanID', + }, + }, + { + name: 'operationName', + type: 'string', + config: {}, + values: ['HTTP GET - root'], + state: { + displayName: 'operationName', + }, + }, + { + name: 'serviceName', + type: 'string', + config: {}, + values: ['db'], + state: { + displayName: 'serviceName', + }, + }, + { + name: 'serviceTags', + type: 'other', + config: {}, + values: [ + [ + { + key: 'service.name', + value: 'db', + }, + { + key: 'job', + value: 'tns/db', + }, + { + key: 'opencensus.exporterversion', + value: 'Jaeger-Go-2.22.1', + }, + { + key: 'host.name', + value: '63d16772b4a2', + }, + { + key: 'ip', + value: '0.0.0.0', + }, + { + key: 'client-uuid', + value: '39fb01637a579639', + }, + ], + ], + state: { + displayName: 'serviceTags', + }, + }, + { + name: 'startTime', + type: 'number', + config: {}, + values: [1627471657255.809], + state: { + displayName: 'startTime', + }, + }, + { + name: 'duration', + type: 'number', + config: {}, + values: [0.459008], + state: { + displayName: 'duration', + }, + }, + { + name: 'logs', + type: 'other', + config: {}, + values: [[]], + state: { + displayName: 'logs', + }, + }, + { + name: 'tags', + type: 'other', + config: {}, + values: [ + [ + { + key: 'http.status_code', + value: 200, + }, + { + key: 'http.method', + value: 'GET', + }, + { + key: 'http.url', + value: '/', + }, + { + key: 'component', + value: 'net/http', + }, + { + key: 'span.kind', + value: 'client', + }, + ], + ], + state: { + displayName: 'tags', + }, + }, + ], + first: ['60ba2abb44f13eae'], + length: 1, +} as any); + +export const otlpResponse = { + batches: [ + { + resource: { + attributes: [ + { key: 'service.name', value: { stringValue: 'db' } }, + { key: 'job', value: { stringValue: 'tns/db' } }, + { key: 'opencensus.exporterversion', value: { stringValue: 'Jaeger-Go-2.22.1' } }, + { key: 'host.name', value: { stringValue: '63d16772b4a2' } }, + { key: 'ip', value: { stringValue: '0.0.0.0' } }, + { key: 'client-uuid', value: { stringValue: '39fb01637a579639' } }, + ], + }, + instrumentationLibrarySpans: [ + { + spans: [ + { + traceId: 'AAAAAAAAAABguiq7RPE+rg==', + spanId: 'cmteMBAvwNA=', + parentSpanId: 'OY8PIaPbma4=', + name: 'HTTP GET - root', + kind: 'SPAN_KIND_CLIENT', + startTimeUnixNano: 1627471657255809000, + endTimeUnixNano: 1627471657256268000, + attributes: [ + { key: 'http.status_code', value: { intValue: 200 } }, + { key: 'http.method', value: { stringValue: 'GET' } }, + { key: 'http.url', value: { stringValue: '/' } }, + { key: 'component', value: { stringValue: 'net/http' } }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index 2a3bca07b13..b5a3364f642 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -93,6 +93,9 @@ const emptyDataQueryResponse = { ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'zipkin', + }, }, }), ], diff --git a/public/app/plugins/datasource/zipkin/types.ts b/public/app/plugins/datasource/zipkin/types.ts index 4bf6be40d51..ba408d5473a 100644 --- a/public/app/plugins/datasource/zipkin/types.ts +++ b/public/app/plugins/datasource/zipkin/types.ts @@ -12,6 +12,7 @@ export type ZipkinSpan = { annotations?: ZipkinAnnotation[]; tags?: { [key: string]: string }; kind?: 'CLIENT' | 'SERVER' | 'PRODUCER' | 'CONSUMER'; + shared?: boolean; }; export type ZipkinEndpoint = { diff --git a/public/app/plugins/datasource/zipkin/utils/testData.ts b/public/app/plugins/datasource/zipkin/utils/testData.ts index 5bd03d83c66..b49e451f44f 100644 --- a/public/app/plugins/datasource/zipkin/utils/testData.ts +++ b/public/app/plugins/datasource/zipkin/utils/testData.ts @@ -29,7 +29,6 @@ export const zipkinResponse: ZipkinSpan[] = [ }, kind: 'CLIENT', }, - { traceId: 'trace_id', parentId: 'span 1 id', @@ -71,9 +70,16 @@ export const traceFrameFields = [ [ { key: 'ipv4', value: '1.0.0.1' }, { key: 'port', value: 42 }, + { key: 'endpointType', value: 'local' }, + ], + [ + { key: 'ipv4', value: '1.0.0.1' }, + { key: 'endpointType', value: 'local' }, + ], + [ + { key: 'ipv6', value: '127.0.0.1' }, + { key: 'endpointType', value: 'remote' }, ], - [{ key: 'ipv4', value: '1.0.0.1' }], - [{ key: 'ipv6', value: '127.0.0.1' }], ], }, { name: 'startTime', values: [0.001, 0.004, 0.006] }, diff --git a/public/app/plugins/datasource/zipkin/utils/transforms.test.ts b/public/app/plugins/datasource/zipkin/utils/transforms.test.ts index 99bea7c61c8..169ada60de7 100644 --- a/public/app/plugins/datasource/zipkin/utils/transforms.test.ts +++ b/public/app/plugins/datasource/zipkin/utils/transforms.test.ts @@ -1,5 +1,6 @@ -import { transformResponse } from './transforms'; +import { transformResponse, transformToZipkin } from './transforms'; import { traceFrameFields, zipkinResponse } from './testData'; +import { MutableDataFrame } from '@grafana/data'; describe('transformResponse', () => { it('transforms response', () => { @@ -7,4 +8,9 @@ describe('transformResponse', () => { expect(dataFrame.fields).toMatchObject(traceFrameFields); }); + it('converts dataframe to ZipkinSpan[]', () => { + const dataFrame = transformResponse(zipkinResponse); + const response = transformToZipkin(new MutableDataFrame(dataFrame)); + expect(response).toMatchObject(zipkinResponse); + }); }); diff --git a/public/app/plugins/datasource/zipkin/utils/transforms.ts b/public/app/plugins/datasource/zipkin/utils/transforms.ts index db193e68fe3..0568875c936 100644 --- a/public/app/plugins/datasource/zipkin/utils/transforms.ts +++ b/public/app/plugins/datasource/zipkin/utils/transforms.ts @@ -1,5 +1,5 @@ import { identity } from 'lodash'; -import { ZipkinAnnotation, ZipkinSpan } from '../types'; +import { ZipkinAnnotation, ZipkinEndpoint, ZipkinSpan } from '../types'; import { DataFrame, FieldType, MutableDataFrame, TraceKeyValuePair, TraceLog, TraceSpanRow } from '@grafana/data'; /** @@ -22,6 +22,9 @@ export function transformResponse(zSpans: ZipkinSpan[]): DataFrame { ], meta: { preferredVisualisationType: 'trace', + custom: { + traceFormat: 'zipkin', + }, }, }); @@ -72,6 +75,16 @@ function transformSpan(span: ZipkinSpan): TraceSpanRow { ]; } + if (span.shared) { + row.tags = [ + { + key: 'shared', + value: span.shared, + }, + ...(row.tags ?? []), + ]; + } + return row; } @@ -100,6 +113,7 @@ function serviceTags(span: ZipkinSpan): TraceKeyValuePair[] { valueToTag('ipv4', endpoint.ipv4), valueToTag('ipv6', endpoint.ipv6), valueToTag('port', endpoint.port), + valueToTag('endpointType', span.localEndpoint ? 'local' : 'remote'), ].filter(identity) as TraceKeyValuePair[]; } @@ -112,3 +126,61 @@ function valueToTag(key: string, value: T): TraceKeyValuePair | undefined value, }; } + +/** + * Transforms data frame to Zipkin response + */ +export const transformToZipkin = (data: MutableDataFrame): ZipkinSpan[] => { + let response: ZipkinSpan[] = []; + + for (let i = 0; i < data.length; i++) { + const span = data.get(i); + response.push({ + traceId: span.traceID, + parentId: span.parentSpanID, + name: span.operationName, + id: span.spanID, + timestamp: span.startTime * 1000, + duration: span.duration * 1000, + ...getEndpoint(span), + annotations: span.logs.length + ? span.logs.map((l: TraceLog) => ({ timestamp: l.timestamp, value: l.fields[0].value })) + : undefined, + tags: span.tags.length + ? span.tags + .filter((t: TraceKeyValuePair) => t.key !== 'kind' && t.key !== 'endpointType' && t.key !== 'shared') + .reduce((tags: { [key: string]: string }, t: TraceKeyValuePair) => { + if (t.key === 'error') { + return { + ...tags, + [t.key]: span.tags.find((t: TraceKeyValuePair) => t.key === 'errorValue').value || '', + }; + } + return { ...tags, [t.key]: t.value }; + }, {}) + : undefined, + kind: span.tags.find((t: TraceKeyValuePair) => t.key === 'kind')?.value, + shared: span.tags.find((t: TraceKeyValuePair) => t.key === 'shared')?.value, + }); + } + + return response; +}; + +// Returns remote or local endpoint object +const getEndpoint = (span: any): { [key: string]: ZipkinEndpoint } | undefined => { + const key = + span.serviceTags.find((t: TraceKeyValuePair) => t.key === 'endpointType')?.value === 'local' + ? 'localEndpoint' + : 'remoteEndpoint'; + return span.serviceName !== 'unknown' + ? { + [key]: { + serviceName: span.serviceName, + ipv4: span.serviceTags.find((t: TraceKeyValuePair) => t.key === 'ipv4')?.value, + ipv6: span.serviceTags.find((t: TraceKeyValuePair) => t.key === 'ipv6')?.value, + port: span.serviceTags.find((t: TraceKeyValuePair) => t.key === 'port')?.value, + }, + } + : undefined; +};